diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 67e20e10c1..436d137a90 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -120,6 +120,7 @@ even when the underlying access was authorized. - States and viewports shown: - Source data: - Attention review: +- Craft review: ## Type of Change diff --git a/apps/presentation/dashboard/src/data/status.ts b/apps/presentation/dashboard/src/data/status.ts index 6793dcd875..66fb9cc558 100644 --- a/apps/presentation/dashboard/src/data/status.ts +++ b/apps/presentation/dashboard/src/data/status.ts @@ -115,7 +115,7 @@ export const todoItemSchema = z.object({ export const todoGroupSchema = z.object({ source_section: z.string().optional().nullable(), total_count: z.number().optional().default(0), - open_count: z.number().optional().default(0), + open_count: z.number().optional(), done_count: z.number().optional().default(0), advancement_done_count: z.number().optional(), items: z.array(todoItemSchema).optional().default([]), @@ -257,7 +257,7 @@ export const goalChannelNotificationProjectionSchema = z.object({ export const projectAssetTodoSummarySchema = z.object({ source_section: z.string().optional().nullable(), - open: z.number().optional().default(0), + open: z.number().optional(), done: z.number().optional().default(0), total: z.number().optional().default(0), advancement_done_count: z.number().optional(), diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx index 8fc20f2c1b..af389bb1c6 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx @@ -1,7 +1,7 @@ import { Bot, Eye, FolderOpen, Menu, RefreshCw, SlidersHorizontal } from "lucide-react"; -import { GoalActivityChip } from "./goal-activity-view"; +import { GoalLifecycleControl, GoalMastheadIdentity } from "./goal-masthead"; -import { localizedGoalState, useWorkspaceI18n } from "./i18n"; +import { useWorkspaceI18n } from "./i18n"; import type { ManagerChannelBinding, ManagerRuntimeSessionReadback } from "../../data/chat"; import type { WorkspaceAgentOption, WorkspaceGoal, WorkspaceGoalTab } from "./personal-workspace-model"; import { WorkspaceSelect } from "./workspace-select"; @@ -9,6 +9,7 @@ import { WorkspaceSelect } from "./workspace-select"; export function ChannelHeader({ agents, conversationScope, + goalLifecycleBusy, managerChannelBinding, managerChatOpen, managerRuntime, @@ -16,13 +17,16 @@ export function ChannelHeader({ onOpenGoalCapabilities, onOpenManagerChat, onOpenManagerSettings, + onOpenWorkspace, onRefresh, + onRequestGoalLifecycle, onOpenNavigation, onSelectGoalTab, onSelectAgent, onReturnManagerHome, refreshState, readOnlySourceLabel, + resumeReviewed, selectedAgentId, selectedGoal, selectedGoalTab, @@ -31,6 +35,7 @@ export function ChannelHeader({ agents: WorkspaceAgentOption[]; /** Steward conversation scope; absent when the host grants no workspace. */ conversationScope?: { options: { disabled?: boolean; label: string; value: string }[]; value: string; onChange: (value: string) => void } | null; + goalLifecycleBusy?: boolean; /** Set while the steward conversation is scoped to a workspace. */ workspaceGrantLabel?: string | null; managerChannelBinding?: ManagerChannelBinding | null; @@ -40,13 +45,18 @@ export function ChannelHeader({ onOpenGoalCapabilities?: () => void; onOpenManagerChat?: () => void; onOpenManagerSettings?: () => void; + onOpenWorkspace?: () => void; onRefresh?: () => void; + /** Absent when the selected source does not authorize Goal lifecycle changes. */ + onRequestGoalLifecycle?: (operation: "stop" | "resume") => void; onOpenNavigation?: () => void; onSelectGoalTab: (tab: WorkspaceGoalTab) => void; onSelectAgent: (agentId: string) => void; onReturnManagerHome?: () => void; refreshState?: "idle" | "loading" | "done" | "error"; readOnlySourceLabel?: string; + /** Resume opens the owner's reviewed preview instead of a local confirmation. */ + resumeReviewed?: boolean; selectedAgentId: string; selectedGoal: WorkspaceGoal | null; selectedGoalTab: WorkspaceGoalTab; @@ -169,8 +179,7 @@ export function ChannelHeader({
-

{selectedGoal?.title ?? t("header.manager")}

- {selectedGoal && !selectedGoal.loadState ?

: null} + {selectedGoal ? undefined)} /> :

{t("header.manager")}

} {workspaceGrantLabel ?

{t("workspace.conversation", { grant: workspaceGrantLabel })}

: null}
{stewardBindingVisible && managerChannelBinding ? ( @@ -232,6 +241,9 @@ export function ChannelHeader({ )}
+ {selectedGoal && !selectedGoal.loadState && onRequestGoalLifecycle ? ( + + ) : null} {selectedGoal && onOpenGoalCapabilities ? ( ) : null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.css b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.css index 4fcd215475..12dca96f77 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.css @@ -13,7 +13,21 @@ .delivery-review-toolbar > div, .delivery-chain-toolbar > div, .delivery-source-actions { display: flex; gap: 8px; flex-wrap: wrap; } .delivery-review-toolbar button, .delivery-source-actions button { display: inline-flex; gap: 8px; align-items: center; } .delivery-snapshot-time, .delivery-boundary, .delivery-chain-toolbar > span { color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); } -.delivery-notice { border: 1px solid var(--pw-line); border-left: 3px solid var(--pw-amber, #a96500); border-radius: 6px; padding: 12px 16px; font-size: var(--text-body); line-height: var(--leading-body); } +.delivery-notice { border-radius: 6px; padding: 10px 14px; background: var(--pw-hover, var(--pw-card)); color: var(--pw-muted); font-size: var(--text-body); line-height: var(--leading-body); } +/* A region failure stays visible and announced, sized to one line rather than a banner. */ +.delivery-notice.is-error { display: flex; align-items: center; gap: 8px; padding: 0; background: none; } +.delivery-notice.is-error svg { flex: none; color: var(--pw-amber, #a96500); } +.delivery-loading { display: grid; gap: 10px; padding: 4px 0; } +.delivery-loading i { display: block; height: 12px; border-radius: 4px; background: linear-gradient(90deg, var(--pw-line) 0%, var(--pw-hover, var(--pw-card)) 50%, var(--pw-line) 100%) 0 0 / 200% 100%; animation: delivery-skeleton 1.4s ease-in-out infinite; } +.delivery-loading i:nth-child(1) { width: 42%; } +.delivery-loading i:nth-child(2) { width: 68%; } +.delivery-loading i:nth-child(3) { width: 54%; } +.goal-overview .delivery-review { padding-top: 24px; border-top: 1px solid var(--pw-line); } +.goal-overview .delivery-review-toolbar h2 { font-size: var(--text-lead); line-height: var(--leading-lead); } +.goal-overview .delivery-review-toolbar button { border-color: transparent; background: transparent; color: var(--pw-muted); transition: background-color var(--motion-fast) var(--ease-standard), color var(--motion-fast) var(--ease-standard); } +.goal-overview .delivery-review-toolbar button:hover:not(:disabled) { background: var(--pw-hover, var(--pw-card)); color: var(--pw-text); } +@keyframes delivery-skeleton { to { background-position: -200% 0; } } +@media (prefers-reduced-motion: reduce) { .delivery-loading i { animation: none; } } .delivery-notice dl { display: flex; gap: 8px 24px; flex-wrap: wrap; margin: 12px 0 0; } .delivery-notice dl > div { display: flex; gap: 8px; } .delivery-notice dd { margin: 0; font-weight: 600; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.tsx b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.tsx index 05ef4f5d45..fb78310272 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.tsx @@ -1,5 +1,5 @@ import { useEffect, useId, useMemo, useRef, useState } from "react"; -import { ArrowRight, Download, ExternalLink, RefreshCw, Search } from "lucide-react"; +import { ArrowRight, CircleAlert, Download, ExternalLink, RefreshCw, Search } from "lucide-react"; import { deliveryReviewMarkdown, fetchDeliveryReview, filterReviewNodes, reviewCoverageIncomplete, reviewNodeColumn, type DeliveryReviewSnapshot, type ReviewFocus, type ReviewGraph, type ReviewNode } from "../../data/delivery-review"; import type { WorkspaceDrawerSelection, WorkspaceGoal, WorkspaceModel, WorkspaceTimelineItem } from "./personal-workspace-model"; import { GoalAcceptanceObservationCard } from "./goal-acceptance-observation-card"; @@ -149,11 +149,12 @@ export function DeliveryReview({ goal, items, userTodos, onSelect, active }: Del
{feedback ?

{feedback}

: null} - {!snapshot ?

{state.kind === "error" ? copy.error : copy.loading}

: <> - {state.kind === "error" ?

{copy.refreshError}

: null} + {!snapshot ? state.kind === "error" ?

{copy.error}

+ :
: <> + {state.kind === "error" ?

{copy.refreshError}

: null}

{copy.observed} · {state.kind === "loading" || (stale && state.kind !== "error") ? · {copy.updating} : null}

- {stale && state.kind === "error" ?

{copy.changed}

: null} + {stale && state.kind === "error" ?

{copy.changed}

: null} {snapshot.goal_map ? usable && mapSource(node) !== null} onOpen={node => { const source = mapSource(node); if (source) onSelect(source); }} /> : snapshot.goal_map === null ?

{copy.workMap.unavailable}

: null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.css new file mode 100644 index 0000000000..d1bd1ec4b9 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.css @@ -0,0 +1,85 @@ +.goal-masthead-breadcrumb { display: flex; align-items: center; gap: 4px; min-width: 0; margin: 0 0 2px; color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); } +.goal-masthead-breadcrumb svg { flex: none; color: var(--pw-faint); } +.goal-masthead-breadcrumb button { padding: 0; border: 0; background: none; color: inherit; font: inherit; cursor: pointer; transition: color var(--motion-fast) var(--ease-standard); } +.goal-masthead-breadcrumb button:hover { color: var(--pw-text); } +.goal-masthead-breadcrumb [aria-current] { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.goal-lifecycle-control { position: relative; flex: none; } +.goal-lifecycle-trigger { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 12px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); cursor: pointer; font-family: inherit; font-size: var(--text-body); font-weight: 500; line-height: var(--leading-body); white-space: nowrap; transition: background-color var(--motion-fast) var(--ease-standard), border-color var(--motion-fast) var(--ease-standard); } +.goal-lifecycle-trigger:hover:not(:disabled) { border-color: var(--pw-line-strong); background: var(--pw-hover); } +.goal-lifecycle-trigger:disabled { color: var(--pw-muted); cursor: progress; } +.goal-lifecycle-trigger:focus-visible, .goal-lifecycle-confirm button:focus-visible, .goal-vital:focus-visible, .goal-masthead-breadcrumb button:focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 2px; } +.goal-lifecycle-confirm { position: absolute; top: calc(100% + 8px); right: 0; z-index: 30; width: min(340px, calc(100vw - 32px)); padding: 16px; border: 1px solid var(--pw-line); border-radius: 12px; background: var(--pw-card); box-shadow: 0 12px 32px rgb(0 0 0 / 8%), 0 1px 2px rgb(0 0 0 / 6%); transform-origin: top right; animation: goal-popover-enter var(--motion-base) var(--ease-out); } +.goal-lifecycle-confirm strong { display: block; color: var(--pw-text); font-size: var(--text-body); font-weight: 600; line-height: var(--leading-body); } +.goal-lifecycle-confirm p { margin: 6px 0 14px; color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); white-space: normal; } +.goal-lifecycle-confirm div { display: flex; justify-content: flex-end; gap: 8px; } +.goal-lifecycle-confirm button { min-height: 34px; padding: 0 12px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); cursor: pointer; font-family: inherit; font-size: var(--text-caption); font-weight: 500; } +.goal-lifecycle-confirm button.is-primary { border-color: var(--pw-text); background: var(--pw-text); color: var(--pw-card); } + +/* A stat row, not a table: values carry the weight, labels recede beneath them. */ +.goal-vitals { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)) auto; column-gap: 8px; border-bottom: 1px solid var(--pw-line); padding: 4px 16px 8px; } +.goal-vital { position: relative; display: flex; flex-direction: column; align-items: flex-start; justify-content: center; gap: 2px; min-width: 0; min-height: 60px; padding: 8px 12px; border: 0; border-radius: 8px; background: transparent; color: var(--pw-text); cursor: pointer; font-family: inherit; text-align: left; transition: background-color var(--motion-fast) var(--ease-standard); } +.goal-vital:hover { background: var(--pw-hover); } +.goal-vital-value { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; overflow: hidden; font-size: var(--text-heading); font-variant-numeric: tabular-nums; font-weight: 600; letter-spacing: -0.02em; line-height: var(--leading-heading); text-overflow: ellipsis; white-space: nowrap; } +.goal-vital-value small { color: var(--pw-faint); font-size: var(--text-body); font-weight: 500; letter-spacing: 0; } +.goal-vital-meta { display: flex; align-items: baseline; gap: 0; width: 100%; min-width: 0; overflow: hidden; color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); white-space: nowrap; } +.goal-vital-label { flex: none; font-weight: 500; } +.goal-vital-sub { min-width: 0; overflow: hidden; color: var(--pw-faint); text-overflow: ellipsis; } +.goal-vital-sub::before { content: "·"; padding: 0 5px; } +.goal-vital.is-danger .goal-vital-value { color: var(--pw-red); } +.goal-vital.is-recede .goal-vital-value { color: var(--pw-faint); font-weight: 500; } +.goal-vital-partial { color: var(--pw-amber); } +.goal-vital-swap { display: inline-block; min-width: 0; overflow: hidden; text-overflow: ellipsis; animation: goal-value-swap var(--motion-base) var(--ease-out); } +.goal-vitals-scope { align-self: center; padding-left: 12px; color: var(--pw-faint); font-size: var(--text-caption); line-height: var(--leading-caption); white-space: nowrap; } + +/* One glyph per state: filled dot (settled), hollow ring (waiting or stopped), spinning arc (observed live). */ +.goal-vital-dot { --goal-dot: var(--pw-track); position: relative; flex: none; width: 8px; height: 8px; box-sizing: border-box; border-radius: 50%; background: var(--goal-dot); } +.goal-vital.is-running .goal-vital-dot { --goal-dot: var(--pw-blue); } +.goal-vital.is-attention .goal-vital-dot { --goal-dot: var(--pw-caution); } +.goal-vital.is-danger .goal-vital-dot { --goal-dot: var(--pw-red); } +.goal-vital.is-waiting .goal-vital-dot, .goal-vital.is-queued .goal-vital-dot { border: 1.5px solid var(--pw-muted); background: transparent; } +.goal-vital.is-stopped .goal-vital-dot { border: 1.5px solid var(--pw-faint); background: transparent; } +.goal-vital-dot.is-live { width: 14px; height: 14px; border: 1.5px solid color-mix(in srgb, var(--goal-dot) 28%, transparent); background: transparent; } +.goal-vital-dot.is-live::before { position: absolute; inset: 2.5px; border-radius: 50%; background: var(--goal-dot); content: ""; } +.goal-vital-dot.is-live::after { position: absolute; inset: -1.5px; border: 1.5px solid transparent; border-top-color: var(--goal-dot); border-radius: 50%; content: ""; animation: pw-spin 1s linear infinite; } + +.goal-work-ribbon { display: flex; gap: 2px; width: 100%; height: 3px; margin-top: 4px; } +.goal-work-ribbon i { display: block; min-width: 3px; border-radius: 2px; background: var(--pw-track); transition: flex-grow var(--motion-section) var(--ease-out); } +.goal-work-ribbon .is-done { background: var(--pw-text); } +.goal-work-ribbon .is-running_observed { background: var(--pw-blue); } +.goal-work-ribbon .is-waiting_dependency { background: repeating-linear-gradient(90deg, var(--pw-faint) 0 3px, transparent 3px 5px); } +.goal-work-ribbon .is-blocked { background: var(--pw-amber); } +.goal-work-ribbon .is-deferred { background: var(--pw-line); } + +.goal-vital-skeleton { cursor: default; gap: 8px; } +.goal-vital-skeleton i { display: block; height: 12px; border-radius: 4px; background: linear-gradient(90deg, var(--pw-line) 0%, var(--pw-hover) 50%, var(--pw-line) 100%) 0 0 / 200% 100%; animation: goal-skeleton 1.4s ease-in-out infinite; } +.goal-vital-skeleton i:first-child { width: 64%; height: 18px; } +.goal-vital-skeleton i:last-child { width: 40%; } + +@keyframes goal-popover-enter { from { opacity: 0; transform: translateY(-4px) scale(.98); } } +@keyframes goal-value-swap { from { opacity: 0; transform: translateY(3px); } } +@keyframes goal-skeleton { to { background-position: -200% 0; } } + +@media (prefers-reduced-motion: reduce) { + .goal-lifecycle-confirm, .goal-vital-swap, .goal-vital-skeleton i, .goal-vital-dot.is-live::after { animation: none; } +} + +@media (max-width: 1199px) { + .goal-vitals { grid-template-columns: repeat(6, minmax(0, 1fr)); } + .goal-vitals-scope { display: none; } +} + +@media (max-width: 900px) { + .goal-vitals { grid-template-columns: repeat(3, minmax(0, 1fr)); padding: 4px 8px 8px; } + .goal-vital { min-height: 56px; padding: 6px 10px; } +} + +@media (max-width: 640px) { + .goal-masthead-breadcrumb { display: none; } + .goal-lifecycle-trigger { width: 36px; padding: 0; justify-content: center; } + .goal-lifecycle-trigger span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } + .goal-vitals { column-gap: 0; padding: 2px 4px 6px; } + .goal-vital { min-height: 48px; padding: 6px 8px; } + .goal-vital-value { font-size: var(--text-lead); line-height: var(--leading-lead); } + .personal-channel:has(.personal-channel-scroll:not([data-active-goal-view="overview"])) .goal-vital-sub { display: none; } +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.tsx new file mode 100644 index 0000000000..e5d96f276e --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.tsx @@ -0,0 +1,218 @@ +import { AlertCircle, ChevronRight, LoaderCircle, Pause, RotateCcw } from "lucide-react"; +import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; +import { + compileGoalWorkspaceDigest, + type DigestExecution, + type GoalWorkspaceDigest, +} from "../../../../../../loopx/control_plane/presentation/goal_workspace_digest.js"; +import { goalHostSurfaces, hostSurfaceLabel, presentGoalActivity } from "./goal-activity"; +import { relativeTime, useExecutionDetail } from "./goal-activity-view"; +import { useWorkspaceI18n, type WorkspaceMessageKey } from "./i18n"; +import { formatCostUsd, formatDurationMs, type WorkspaceGoal, type WorkspaceModel, type WorkspaceTimelineItem } from "./personal-workspace-model"; +import "./goal-masthead.css"; + +function digestExecution(execution: WorkspaceGoal["execution"]): DigestExecution | undefined { + if (!execution) return undefined; + if (execution.kind !== "running") return execution.kind === "idle" ? { kind: "idle" } : { kind: "unknown" }; + return { kind: "running", observed: !execution.hostClaimed, quiet: execution.quiet, lastActivityAt: execution.lastActivityAt }; +} + +export function useGoalWorkspaceDigest(goal: WorkspaceGoal, userTodos: WorkspaceModel["userTodos"], items: WorkspaceTimelineItem[]): GoalWorkspaceDigest { + return useMemo(() => compileGoalWorkspaceDigest({ + observedAt: new Date().toISOString(), + activation: goal.activationState, + todos: goal.agentTodos.map((todo) => ({ + todoId: todo.todoId, done: todo.done, status: todo.status, taskClass: todo.taskClass, dependencies: todo.dependencies, + })), + doneCount: goal.doneTodoCount, + openCount: goal.openTodoCount, + runningTodoIds: items.flatMap((item) => item.kind === "run" && item.run.goalId === goal.goalId && item.run.status === "running" && item.run.todoId ? [item.run.todoId] : []), + attention: goal.loadState === "error" ? null : userTodos.filter((item) => item.goalId === goal.goalId).map((item) => ({ blocking: item.blocking, updatedAt: item.updatedAt })), + attentionSummary: goal.attentionSummary, + usage: goal.usage, + execution: digestExecution(goal.execution), + activity: (goal.hostThreadActivity?.threads ?? []).map((thread) => ({ actor: hostSurfaceLabel(thread.hostSurface), at: thread.lastEventAt })), + }), [goal, userTodos, items]); +} + +export function GoalMastheadIdentity({ goal, onOpenWorkspace }: { goal: WorkspaceGoal; onOpenWorkspace: () => void }) { + const { t } = useWorkspaceI18n(); + const agents = (goal.agentLanes ?? []).map((lane) => lane.label); + const context = [ + agents.length > 1 ? t("masthead.team", { count: agents.length }) : agents[0], + goal.repository?.label, + ].filter(Boolean); + return ( + <> + +

{goal.title}

+ {context.length ?

{context.join(" · ")}

: null} + + ); +} + +/** The one consequential control, confirmed once with the owner's stated effect. + * A reviewed Resume already shows the owner's preview, so it is not asked twice. */ +export function GoalLifecycleControl({ busy, goal, onRequest, resumeReviewed }: { + busy: boolean; goal: WorkspaceGoal; onRequest: (operation: "stop" | "resume") => void; resumeReviewed: boolean; +}) { + const { t } = useWorkspaceI18n(); + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const confirmRef = useRef(null); + const titleId = useId(); + const stopped = goal.activationState === "stopped"; + const operation = stopped ? "resume" : "stop"; + useEffect(() => { + if (!open) return; + confirmRef.current?.focus(); + const close = (event: PointerEvent | KeyboardEvent) => { + if (event instanceof KeyboardEvent ? event.key !== "Escape" : rootRef.current?.contains(event.target as Node)) return; + setOpen(false); + if (event instanceof KeyboardEvent) triggerRef.current?.focus(); + }; + document.addEventListener("pointerdown", close); + document.addEventListener("keydown", close); + return () => { + document.removeEventListener("pointerdown", close); + document.removeEventListener("keydown", close); + }; + }, [open]); + useEffect(() => setOpen(false), [goal.goalId, goal.activationState]); + const label = busy ? t("masthead.stopping") : t(stopped ? "sidebar.resume" : "sidebar.stop"); + return ( +
+ + {open ? ( +
+ {t(stopped ? "masthead.resumeTitle" : "masthead.stopTitle")} +

{t(stopped ? "masthead.resumeEffect" : "masthead.stopEffect")}

+
+ + +
+
+ ) : null} +
+ ); +} + +export type GoalVitalRegion = "overview" | "tasks" | "attention" | "usage"; + +const ribbonOrder = ["done", "running_observed", "open", "waiting_dependency", "blocked", "deferred"] as const; + +function Vital({ children, extra, label, onClick, sub, tone, valueKey }: { + children: ReactNode; extra?: ReactNode; label: string; onClick: () => void; sub?: ReactNode; tone?: string; valueKey?: string; +}) { + return ( + + ); +} + +/** Holds the strip's geometry while a Goal's first read is in flight. */ +export function GoalVitalsSkeleton() { + const { t } = useWorkspaceI18n(); + return ( +
+ {Array.from({ length: 6 }, (_, index) => )} +
+ ); +} + +export function GoalVitals({ goal, items, onFocusRegion, userTodos }: { + goal: WorkspaceGoal; items: WorkspaceTimelineItem[]; onFocusRegion: (region: GoalVitalRegion) => void; userTodos: WorkspaceModel["userTodos"]; +}) { + const { locale, t } = useWorkspaceI18n(); + const digest = useGoalWorkspaceDigest(goal, userTodos, items); + const activity = presentGoalActivity(goal); + const detail = useExecutionDetail(goal.execution); + const finishing = digest.lifecycle.finishingTurn; + const stateSub = finishing ? t("vitals.turnFinishing") + : activity.alsoKey ? t(activity.alsoKey as WorkspaceMessageKey, { host: goalHostSurfaces(goal).map(hostSurfaceLabel).join(" / ") }) + : detail.length ? detail.join(" · ") + : goal.execution?.kind === "unknown" ? t("vitals.activityUnknown") : null; + + const { work } = digest; + const workSub = [ + work.classes.running_observed ? t("vitals.workRunning", { count: work.classes.running_observed }) : null, + t("vitals.workOpen", { count: work.open }), + work.classes.blocked ? t("vitals.workBlocked", { count: work.classes.blocked }) : null, + work.done === null ? t("vitals.workDoneUnknown") : null, + ].filter(Boolean).join(" · "); + const ribbonTotal = ribbonOrder.reduce((sum, name) => sum + work.classes[name], 0); + + const windowLabel = digest.usage.window === "7d" ? t("vitals.window7d") : digest.usage.window === "24h" ? t("vitals.window24h") : t("vitals.notMeasured"); + const needs = digest.needsYou; + const last = digest.lastActivity; + const lastTime = last ? relativeTime(last.at, locale) : null; + const stateLabel = finishing ? t("vitals.stopping") : t(activity.labelKey as WorkspaceMessageKey); + + return ( +
+ onFocusRegion("overview")} sub={stateSub} + tone={finishing ? "stopped" : activity.tone} valueKey={`${goal.goalId}:${stateLabel}`}> + + {stateLabel} + + onFocusRegion("tasks")} valueKey={`${goal.goalId}:${work.done}:${work.total}:${work.open}`} + sub={work.coverage === "partial" && work.projectedOpen !== null + ? {t("vitals.workPartial", { listed: work.listedOpen, projected: work.projectedOpen })} : workSub} + extra={ribbonTotal > 0 ? {ribbonOrder.map((name) => work.classes[name] + ? : null)} : null}> + {work.total === null ? work.open : {work.done}/{work.total}} + + onFocusRegion("attention")} valueKey={`${goal.goalId}:${needs.availability}:${needs.availability === "ok" ? needs.blocking : ""}`} + tone={needs.availability === "ok" && needs.blocking !== null && needs.blocking > 0 ? "danger" : needs.availability === "ok" ? "recede" : undefined} + sub={needs.availability !== "ok" ? null : [ + needs.blocking === 0 ? t("vitals.needsYouNone") : null, + needs.coverage !== "complete" ? t("vitals.needsYouPartial", { count: needs.listed }) : null, + needs.oldestWaitingSince ? t("vitals.needsYouOldest", { time: relativeTime(needs.oldestWaitingSince, locale) ?? "" }) : null, + needs.optional ? t("vitals.needsYouOptional", { count: needs.optional }) : null, + ].filter(Boolean).join(" · ")}> + {needs.availability !== "ok" ? t("vitals.unavailable") : needs.blocking === null ? "—" + : <>{needs.blocking > 0 ? : null}{needs.blocking}} + + onFocusRegion("usage")} sub={windowLabel} + tone={digest.usage.agentTimeMs === null ? "recede" : undefined} valueKey={`${goal.goalId}:${digest.usage.window}:${digest.usage.agentTimeMs}`}> + {digest.usage.agentTimeMs === null ? "—" : formatDurationMs(digest.usage.agentTimeMs)} + + onFocusRegion("usage")} sub={windowLabel} + tone={digest.usage.costUsd === null ? "recede" : undefined} valueKey={`${goal.goalId}:${digest.usage.window}:${digest.usage.costUsd}`}> + {digest.usage.costUsd === null ? "—" : formatCostUsd(digest.usage.costUsd)} + + onFocusRegion("overview")} + sub={last ? last.actor || t("vitals.turn") : t("vitals.noActivity")} tone={last ? undefined : "recede"}> + {last && lastTime ? lastTime : "—"} + + {t("vitals.scope")} +
+ ); +} 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 cfffa84ed3..219e100b15 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.css @@ -1,30 +1,103 @@ .personal-goal-view-panel { min-width: 0; } .personal-goal-view-panel[hidden] { display: none; } +.personal-goal-view-panel:not([hidden]) { animation: goal-panel-enter var(--motion-section) var(--ease-out); } .personal-goal-view-panel[data-goal-panel="tasks"] { height: 100%; } -.goal-overview { display: grid; gap: 24px; min-width: 0; } + +/* A document, not a card grid: the Direction is the one hero; the rest recedes. */ +.goal-overview { display: grid; gap: 32px; min-width: 0; padding-top: 8px; } .goal-overview h2, .goal-overview h3, .goal-overview p { margin: 0; } -.goal-overview h2 { font-size: var(--text-heading); line-height: var(--leading-heading); font-weight: 600; } -.goal-overview h3 { font-size: var(--text-body); line-height: var(--leading-body); font-weight: 600; } -.goal-overview button { display: inline-flex; align-items: center; gap: 8px; min-height: 40px; padding: 8px 12px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); font: inherit; cursor: pointer; text-align: left; } +.goal-overview button { display: inline-flex; align-items: center; gap: 8px; min-height: 40px; padding: 8px 12px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); font: inherit; cursor: pointer; text-align: left; transition: background-color var(--motion-fast) var(--ease-standard), border-color var(--motion-fast) var(--ease-standard), color var(--motion-fast) var(--ease-standard); } +.goal-overview button:hover { border-color: var(--pw-line-strong); background: var(--pw-hover); } .goal-overview button:focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 3px; } -.goal-overview-heading, .goal-overview-summary header { display: flex; align-items: center; justify-content: space-between; gap: 16px; } -.goal-overview-heading button, .goal-overview-summary header span { font-size: var(--text-caption); line-height: var(--leading-caption); color: var(--pw-muted); } -.goal-overview-summary { display: grid; gap: 20px; } -.goal-overview-summary.has-attention { grid-template-columns: 1fr 1fr; } -.goal-overview-summary > section { display: grid; align-content: start; gap: 16px; padding: 20px; border: 1px solid var(--pw-line); border-radius: 12px; background: var(--pw-card); min-width: 0; } -.goal-overview-summary p, .goal-overview-source-note { color: var(--pw-muted); font-size: var(--text-body); line-height: var(--leading-body); } +.goal-overview button svg { flex: none; } + +.goal-overview-summary { display: grid; gap: 32px; align-items: start; } +.goal-overview-summary.has-attention { grid-template-columns: minmax(0, 1fr) minmax(260px, 320px); } +.goal-overview-summary > section { display: grid; align-content: start; gap: 12px; min-width: 0; } .goal-overview-summary strong, .goal-overview-summary p, .goal-overview-summary button span { overflow-wrap: anywhere; } .goal-overview-summary ul { list-style: none; margin: 0; padding: 0; } -.goal-overview-summary li + li { margin-top: 8px; } -.goal-overview-summary li button { display: flex; justify-content: space-between; width: 100%; } -.goal-overview-summary .goal-overview-run { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; } + +.goal-overview-heading, .goal-overview-attention header { display: flex; align-items: center; gap: 8px; min-height: 32px; } +.goal-overview-heading h2, .goal-overview-attention h2 { color: var(--pw-muted); font-size: var(--text-caption); font-weight: 500; line-height: var(--leading-caption); } +.goal-overview-heading > span { color: var(--pw-faint); font-size: var(--text-caption); line-height: var(--leading-caption); } +.goal-overview-heading > span::before { content: "·"; padding-right: 8px; } +.goal-overview .goal-overview-heading button { min-height: 32px; margin-left: auto; padding: 4px 8px; border-color: transparent; background: transparent; color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); } +.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; } +.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); } + +.goal-overview-progress { display: flex; align-items: center; gap: 12px; margin-top: 8px; color: var(--pw-muted); font-size: var(--text-caption); font-variant-numeric: tabular-nums; line-height: var(--leading-caption); } +.goal-criteria-meter { display: flex; flex: 0 1 160px; gap: 3px; height: 4px; } +.goal-criteria-meter i { flex: 1; border-radius: 2px; background: var(--pw-line); } +.goal-criteria-meter .is-verified { background: var(--pw-green); } +.goal-criteria-meter .is-failing { background: var(--pw-red); } + +.goal-overview-criteria { border-top: 1px solid var(--pw-line); } +.goal-overview-criteria li { display: grid; grid-template-columns: 16px minmax(0, 1fr) auto; align-items: center; gap: 10px; min-height: 40px; padding: 8px 0; border-bottom: 1px solid var(--pw-line); font-size: var(--text-body); line-height: var(--leading-body); transition: opacity var(--motion-base) var(--ease-out), transform var(--motion-base) var(--ease-out); } +.goal-overview-criteria svg { color: var(--pw-faint); } +.goal-overview-criteria small { color: var(--pw-faint); font-size: var(--text-caption); line-height: var(--leading-caption); } +.goal-overview-criteria .is-verified svg { color: var(--pw-green); } +.goal-overview-criteria .is-verified > span { color: var(--pw-muted); } +.goal-overview-criteria .is-failing svg, .goal-overview-criteria .is-failing small { color: var(--pw-red); } + +.goal-overview-summary .goal-overview-run { display: grid; grid-template-columns: 1fr auto; gap: 2px 12px; margin-top: 4px; padding: 12px 14px; } .goal-overview-run small { color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); } -.goal-overview-run strong { grid-row: 2; font-size: var(--text-body); line-height: var(--leading-body); } -.goal-overview-run svg { grid-column: 2; grid-row: 1 / 3; } -.goal-overview-usage { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 24px; padding-block: 12px; border-block: 1px solid var(--pw-line); } -.goal-overview-usage h3 { color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); font-weight: 400; } -.goal-overview-usage dl { display: flex; flex-wrap: wrap; gap: 12px 24px; margin: 0; } -.goal-overview-usage dl > div { display: flex; align-items: baseline; gap: 8px; font-size: var(--text-caption); line-height: var(--leading-caption); } -.goal-overview-usage dt { color: var(--pw-muted); } -.goal-overview-usage dd { margin: 0; font-variant-numeric: tabular-nums; } -@media (max-width: 640px) { .goal-overview-summary.has-attention { grid-template-columns: 1fr; } } +.goal-overview-run strong { grid-row: 2; font-size: var(--text-body); font-weight: 500; line-height: var(--leading-body); } +.goal-overview-run svg { grid-column: 2; grid-row: 1 / 3; color: var(--pw-faint); transition: transform var(--motion-fast) var(--ease-out), color var(--motion-fast) var(--ease-out); } +.goal-overview-run:hover svg { color: var(--pw-text); transform: translateX(2px); } + +.goal-overview-attention { padding-left: 24px; border-left: 1px solid var(--pw-line); } +.goal-overview-attention:focus { outline: none; } +.goal-overview-attention:focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 3px; } +.goal-overview-count { min-width: 20px; padding: 0 6px; border-radius: 9999px; background: var(--pw-line); color: var(--pw-muted); font-size: var(--text-caption); font-variant-numeric: tabular-nums; font-weight: 600; line-height: 20px; text-align: center; } +.goal-overview-count.is-blocking { background: var(--pw-red-bg); color: var(--pw-red); } +.goal-overview-attention header small { color: var(--pw-faint); } +.goal-overview-attention li { transition: opacity var(--motion-base) var(--ease-out), transform var(--motion-base) var(--ease-out); } +.goal-overview-attention li + li { border-top: 1px solid var(--pw-line); } +.goal-overview .goal-overview-attention li button { display: grid; grid-template-columns: 16px minmax(0, 1fr) 14px; align-items: start; gap: 10px; width: 100%; min-height: 48px; padding: 10px 8px; border: 0; border-radius: 6px; background: transparent; } +.goal-overview .goal-overview-attention li button:hover { background: var(--pw-hover); } +.goal-overview-attention li svg { margin-top: 2px; } +.goal-overview-attention li svg:first-child { color: var(--pw-caution); } +.goal-attention-text { display: grid; gap: 2px; min-width: 0; font-size: var(--text-body); line-height: var(--leading-body); } +.goal-attention-text time { color: var(--pw-faint); font-size: var(--text-caption); font-variant-numeric: tabular-nums; line-height: var(--leading-caption); } +.goal-overview-attention li svg:last-child { color: var(--pw-faint); transition: transform var(--motion-fast) var(--ease-out), color var(--motion-fast) var(--ease-out); } +.goal-overview-attention li button:hover svg:last-child { color: var(--pw-text); transform: translateX(2px); } +.goal-overview .goal-overview-more { justify-self: start; min-height: 32px; padding: 4px 8px; border-color: transparent; background: transparent; color: var(--pw-muted); font-size: var(--text-caption); } + +.goal-overview-source-note { color: var(--pw-muted); font-size: var(--text-body); line-height: var(--leading-body); } + +.personal-composer-folded { display: flex; align-items: center; justify-content: center; gap: 8px; } +.personal-composer-folded .personal-composer-tools { position: relative; flex: none; margin: 0; } +.personal-composer-folded .personal-composer-tools > summary { padding-inline: 8px; border-radius: 6px; list-style: none; } +.personal-composer-folded .personal-composer-tools > summary::-webkit-details-marker { display: none; } +.personal-composer-folded .personal-composer-tools > summary:hover { background: var(--pw-hover); color: var(--pw-text); } +.personal-composer-folded .personal-composer-tools .personal-quick-prompts { position: absolute; right: 0; bottom: calc(100% + 8px); z-index: 20; display: grid; gap: 4px; width: max-content; max-width: min(320px, calc(100vw - 32px)); padding: 6px; border: 1px solid var(--pw-line); border-radius: 12px; background: var(--pw-card); box-shadow: 0 12px 32px rgb(0 0 0 / 8%), 0 1px 2px rgb(0 0 0 / 6%); animation: goal-panel-enter var(--motion-base) var(--ease-out); } +.personal-composer-folded .personal-quick-prompts button { justify-content: flex-start; } +.personal-composer-pill { display: flex; align-items: center; gap: 10px; flex: 0 1 560px; min-width: 0; min-height: 44px; padding: 0 16px; border: 1px solid var(--pw-line-strong); border-radius: 16px; background: var(--pw-card); color: var(--pw-faint); cursor: text; font: inherit; font-size: var(--text-body); line-height: var(--leading-body); text-align: left; transition: border-color var(--motion-fast) var(--ease-standard), color var(--motion-fast) var(--ease-standard); animation: goal-panel-enter var(--motion-section) var(--ease-out); } +.personal-composer-pill span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.personal-composer-pill:hover { border-color: var(--pw-muted); color: var(--pw-muted); } +.personal-composer-pill:focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 2px; } +.personal-workspace-shell[data-pw-theme="loopx"] .personal-composer-pill { border-radius: 6px; } + +@starting-style { + .goal-overview-criteria li, .goal-overview-attention li { opacity: 0; transform: translateY(4px); } +} + +@keyframes goal-panel-enter { from { opacity: 0; transform: translateY(4px); } } + +@media (prefers-reduced-motion: reduce) { + .personal-goal-view-panel:not([hidden]), .personal-composer-pill, .personal-composer-folded .personal-quick-prompts { animation: none; } + .goal-overview-criteria li, .goal-overview-attention li, .goal-overview-run svg, .goal-overview-attention li svg:last-child { transition: none; } +} + +@media (max-width: 900px) { + .goal-overview-summary.has-attention { grid-template-columns: 1fr; } + .goal-overview-attention { padding: 16px 0 0; border-top: 1px solid var(--pw-line); border-left: 0; } +} + +@media (max-width: 640px) { + .goal-overview { gap: 24px; } + .goal-overview-objective { font-size: var(--text-lead); line-height: var(--leading-lead); } +} 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 2b1f5abe76..fbe7ea574b 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx @@ -1,48 +1,102 @@ -import { ArrowRight, Info } from "lucide-react"; +import { ArrowRight, CircleCheck, CircleDashed, CircleX, Hand, Info } from "lucide-react"; import { DeliveryReview } from "./delivery-review"; +import { relativeTime } from "./goal-activity-view"; +import { goalAcceptanceContractCopy } from "./delivery-review-copy"; +import { useGoalWorkspaceDigest } from "./goal-masthead"; import { GoalAcceptanceObservationCard } from "./goal-acceptance-observation-card"; -import { formatCostUsd, formatDurationMs, formatTokenCount, formatUsageValue, type WorkspaceDrawerSelection, type WorkspaceGoal, type WorkspaceGoalTab, type WorkspaceModel, type WorkspaceTimelineItem } from "./personal-workspace-model"; -import { localizedGoalState, useWorkspaceI18n } from "./i18n"; +import type { WorkspaceDrawerSelection, WorkspaceGoal, WorkspaceGoalTab, WorkspaceModel, WorkspaceTimelineItem } from "./personal-workspace-model"; +import { useWorkspaceI18n } from "./i18n"; import "./goal-overview.css"; +export const goalOverviewAttentionId = "goal-overview-attention"; + +type CriterionState = "verified" | "failing" | "unverified"; + +/** Owner-recorded results for the current contract revision; anything else is not yet verified. */ +function criterionStates(contract: Extract["goal_acceptance_contract"]>, { enabled: true }>) { + const verification = contract.verification?.contract_revision === contract.revision ? contract.verification : null; + const results = new Map((verification?.results ?? []).map((result) => [result.criterion_id, result.passed])); + return contract.criteria.map((criterion) => ({ + ...criterion, + state: (results.has(criterion.id) ? (results.get(criterion.id) ? "verified" : "failing") : "unverified") as CriterionState, + })); +} + export function GoalOverview({ active, goal, items, userTodos, readOnly, onOpenDetails, onSelect, onView }: { active: boolean; goal: WorkspaceGoal; items: WorkspaceTimelineItem[]; userTodos: WorkspaceModel["userTodos"]; readOnly: boolean; onOpenDetails: () => void; onSelect: (selection: WorkspaceDrawerSelection) => void; onView: (view: WorkspaceGoalTab) => void; }) { const { t, locale } = useWorkspaceI18n(); const copy = locale === "zh-CN" ? { - progress: "当前进展", attention: "需要你", none: "当前没有已加载的待处理决定。", details: "Goal 信息", - tasks: "查看任务", usage: "最近 24 小时", execution: "执行记录", + direction: "方向", agentSummary: "Agent 的进展摘要", attention: "需要你", details: "Goal 信息", + tasks: "查看任务", execution: "执行记录", optional: "{count} 项可选", + noTarget: "尚未声明可衡量目标。目标通过 Goal 的验收配置声明。", + verified: "{passed}/{total} 项条件已核验", criterion: { verified: "已核验", failing: "未通过", unverified: "尚未核验" }, remote: "此来源仅提供同步的状态与验收观察,交付链需要实时本机来源。", } : { - progress: "Current progress", attention: "Needs you", none: "No pending decisions are loaded.", details: "Goal information", - tasks: "View tasks", usage: "Last 24 hours", execution: "Execution", + direction: "Direction", agentSummary: "Agent's progress summary", attention: "Needs you", details: "Goal information", + tasks: "View tasks", execution: "Execution", optional: "{count} optional", + noTarget: "No measurable target declared. Targets are declared through the Goal's acceptance configuration.", + verified: "{passed} of {total} criteria verified", criterion: { verified: "Verified", failing: "Failing", unverified: "Not yet verified" }, remote: "This source provides synchronized status and acceptance observations. The delivery chain requires the live local source.", }; const attention = userTodos.filter(item => item.goalId === goal.goalId); + const needs = useGoalWorkspaceDigest(goal, userTodos, items).needsYou; + const blocking = needs.availability === "ok" ? needs.blocking : null; + const optional = needs.availability === "ok" ? needs.optional : null; + const totalAttention = blocking !== null && optional !== null ? blocking + optional : null; + const showAttention = attention.length > 0 || (totalAttention !== null && totalAttention > 0); + const moreAttention = needs.availability !== "ok" || needs.coverage !== "complete" || (totalAttention ?? attention.length) > 3; const run = items.find((item): item is Extract => item.kind === "run" && item.run.goalId === goal.goalId); + const observation = goal.acceptanceObservation?.goal_id === goal.goalId ? goal.acceptanceObservation : null; + const contract = observation?.goal_acceptance_contract?.enabled === true ? observation.goal_acceptance_contract : null; + const criteria = contract ? criterionStates(contract) : []; + const passed = criteria.filter(criterion => criterion.state === "verified").length; + const acceptanceCopy = goalAcceptanceContractCopy[locale === "zh-CN" ? "zh-CN" : "en"]; return
-

{t("header.overview")}

-
-

{copy.progress}

{localizedGoalState(goal.state, locale)}
- {goal.nextSentence} - {goal.agentSentence && goal.agentSentence !== goal.nextSentence ?

{goal.agentSentence}

: null} - {!attention.length ?

{copy.none}

: null} - {run ? : null} +
+
+
+

{copy.direction}

+ {contract ? {acceptanceCopy.verificationState[contract.status]} : null} + +
+ {contract ? <> +

{contract.objective || acceptanceCopy.unknown}

+ {criteria.length ? <> +
+ {copy.verified.replace("{passed}", String(passed)).replace("{total}", String(criteria.length))} + {criteria.map(criterion => )} +
+
    {criteria.map(criterion =>
  • + {criterion.state === "verified" ? : criterion.state === "failing" ? : } + {criterion.description}{copy.criterion[criterion.state]} +
  • )}
+ : null} +

{copy.agentSummary} {goal.nextSentence}

+ : <> +

{goal.nextSentence}

+ {goal.agentSentence && goal.agentSentence !== goal.nextSentence ?

{goal.agentSentence}

: null} +

{copy.agentSummary} {copy.noTarget}

+ } + {run ? : null}
- {attention.length ?

{copy.attention}

{attention.length}
-
    {attention.slice(0, 3).map(item =>
  • )}
- {attention.length > 3 ? : null} + {showAttention ?
+
+

{copy.attention}

+ {blocking ?? "—"} + {optional ? {copy.optional.replace("{count}", String(optional))} : null} +
+
    {attention.slice(0, 3).map(item =>
  • )}
+ {needs.availability === "ok" && needs.coverage !== "complete" ? {t("vitals.needsYouPartial", { count: needs.listed })} : null} + {moreAttention ? : null}
: null}
-
-

{copy.usage}

-
-
{t("drawer.tokensShort")}
{formatUsageValue(goal.usage?.tokens24h, t("drawer.usageNotMeasured"), formatTokenCount)}
-
{t("drawer.costShort")}
{formatUsageValue(goal.usage?.costUsd24h, t("drawer.usageNotMeasured"), formatCostUsd)}
-
{t("drawer.durationShort")}
{formatUsageValue(goal.usage?.durationMs24h, t("drawer.usageNotMeasured"), formatDurationMs)}
-
-
{readOnly ? <>

{copy.remote}

: }
; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-tasks-view.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-tasks-view.tsx index c47cf33622..e8c93ff74a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-tasks-view.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-tasks-view.tsx @@ -10,6 +10,7 @@ import type { import { localizedAttentionAge, useWorkspaceI18n } from "./i18n"; import { CompletedTaskLane } from "./completed-task-lane"; import { MarkdownText } from "./markdown"; +import { isGoalWorkTodo } from "../../../../../../loopx/control_plane/presentation/goal_workspace_digest.js"; function TaskLane({ children, @@ -164,11 +165,11 @@ export function GoalTasksView({ : "all"; const laneMatches = (agentId?: string | null) => selectedLaneId === "all" || agentId === selectedLaneId; const openAgentTodos = goal.agentTodos - .filter((todo) => todo.taskClass !== "continuous_monitor" && !todo.done) + .filter((todo) => isGoalWorkTodo(todo) && !todo.done) .filter((todo) => laneMatches(todo.claimedBy)) .sort((left, right) => priorityRank(left) - priorityRank(right)); const doneAgentTodos = goal.agentTodos - .filter((todo) => todo.taskClass !== "continuous_monitor" && todo.done) + .filter((todo) => isGoalWorkTodo(todo) && todo.done) .filter((todo) => laneMatches(todo.claimedBy)); const scheduleItems = items.filter((item): item is Extract => item.kind === "schedule" && laneMatches(item.schedule.agentId)); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 8ca9bfe6a1..4345ef5f3e 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -1371,6 +1371,41 @@ const en = { "timeline.review": "Review", "timeline.reviewAndConfirm": "Review and confirm", "timeline.waitingConfirmation": "Needs your confirmation", + "masthead.breadcrumb": "Location", + "masthead.workspace": "Workspace", + "masthead.goals": "Goals", + "masthead.team": "{count} agents", + "masthead.stopping": "Stopping…", + "masthead.stopTitle": "Stop this Goal?", + "masthead.stopEffect": "Stops new automatic Turns and removes the Goal from active attention. A tool call that is already running finishes. Tasks, history, evidence and configuration are kept. Only Resume makes it eligible again.", + "masthead.resumeTitle": "Resume this Goal?", + "masthead.resumeEffect": "Makes the Goal eligible for automatic Turns again. Work still follows quota, gates and task constraints.", + "vitals.label": "Goal vitals", + "vitals.scope": "Counts cover this Goal", + "vitals.state": "State", + "vitals.stopping": "Stopping", + "vitals.turnFinishing": "Turn finishing", + "vitals.activityUnknown": "activity unknown", + "vitals.work": "Work", + "vitals.workOpen": "{count} open", + "vitals.workDoneUnknown": "done not reported", + "vitals.workRunning": "{count} running", + "vitals.workBlocked": "{count} blocked", + "vitals.workPartial": "partial · {listed} of {projected} open listed", + "vitals.needsYou": "Needs you", + "vitals.needsYouNone": "Nothing waiting", + "vitals.needsYouOldest": "oldest {time}", + "vitals.needsYouOptional": "+{count} optional", + "vitals.needsYouPartial": "{count} listed · oldest unknown", + "vitals.unavailable": "Unavailable", + "vitals.agentTime": "Agent time", + "vitals.cost": "Cost", + "vitals.window7d": "last 7 days", + "vitals.window24h": "last 24 hours", + "vitals.notMeasured": "not measured", + "vitals.lastActivity": "Last activity", + "vitals.noActivity": "No observed activity", + "vitals.turn": "Turn", }; export type WorkspaceMessageKey = keyof typeof en; @@ -2734,6 +2769,41 @@ const zhCN: Record = { "timeline.review": "查看处理方式", "timeline.reviewAndConfirm": "查看并确认", "timeline.waitingConfirmation": "待你确认", + "masthead.breadcrumb": "位置", + "masthead.workspace": "工作台", + "masthead.goals": "Goals", + "masthead.team": "{count} 个 Agent", + "masthead.stopping": "正在停止…", + "masthead.stopTitle": "停止这个 Goal?", + "masthead.stopEffect": "停止新的自动 Turn,并将 Goal 移出活跃关注。已在运行的工具调用会执行完。任务、历史、证据和配置都会保留。只有恢复才能重新获得调度资格。", + "masthead.resumeTitle": "恢复这个 Goal?", + "masthead.resumeEffect": "Goal 重新获得自动 Turn 的调度资格;实际执行仍受 quota、Gate 和任务约束。", + "vitals.label": "Goal 概况", + "vitals.scope": "统计范围:本 Goal", + "vitals.state": "状态", + "vitals.stopping": "正在停止", + "vitals.turnFinishing": "Turn 收尾中", + "vitals.activityUnknown": "活动未知", + "vitals.work": "工作", + "vitals.workOpen": "{count} 项未完成", + "vitals.workDoneUnknown": "未报告完成数", + "vitals.workRunning": "{count} 项执行中", + "vitals.workBlocked": "{count} 项受阻", + "vitals.workPartial": "部分 · 已列出 {projected} 项中的 {listed} 项", + "vitals.needsYou": "等你决定", + "vitals.needsYouNone": "暂无等待", + "vitals.needsYouOldest": "最早 {time}", + "vitals.needsYouOptional": "另有 {count} 项可选", + "vitals.needsYouPartial": "已列出 {count} 项 · 最早时间未知", + "vitals.unavailable": "无法读取", + "vitals.agentTime": "Agent 用时", + "vitals.cost": "成本", + "vitals.window7d": "近 7 天", + "vitals.window24h": "近 24 小时", + "vitals.notMeasured": "未采集", + "vitals.lastActivity": "最近活动", + "vitals.noActivity": "暂无观测到的活动", + "vitals.turn": "Turn", }; const messages: Record> = { diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index fd6293bf5c..f7042ffb27 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -8,6 +8,7 @@ import type { AttentionDetails } from "./attention-details"; import type { WorkspaceLoadError, WorkspaceReadScope } from "../../data/workspace-progressive-status"; import type { TodoItem } from "../../data/status"; import { goalWorkKind, type GoalHostThreadActivity, type WorkspaceGoalExecution } from "./goal-activity"; +import type { DigestAttentionSummary } from "../../../../../../loopx/control_plane/presentation/goal_workspace_digest.js"; export type WorkspaceGoalState = | "需修复" | "等你" @@ -168,6 +169,8 @@ export type WorkspaceGoal = { boundHostSurfaces?: string[]; /** Completed agent Todo count from the status payload; item lists only carry open Todos. */ doneTodoCount?: number; + /** Open agent Todo count projected beside a possibly bounded item list. */ + openTodoCount?: number; execution?: WorkspaceGoalExecution; goalId: string; /** Bound host threads as observed from the host's own records. */ @@ -175,6 +178,7 @@ export type WorkspaceGoal = { latestActivity?: string; needsYou?: string | null; needsYouBlocking?: boolean; + attentionSummary?: DigestAttentionSummary; nextSentence: string; repository?: WorkspaceRepositoryContext; state: WorkspaceGoalState; 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 ecfe0d4380..262dd21cdd 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 @@ -50,7 +50,8 @@ import { ContextDrawer } from "./context-drawer"; import { monitorScheduleReadback } from "./monitor-readback"; import { GoalSidebar } from "./goal-sidebar"; import { GoalTasksView } from "./goal-tasks-view"; -import { GoalOverview } from "./goal-overview"; +import { GoalOverview, goalOverviewAttentionId } from "./goal-overview"; +import { GoalVitals, GoalVitalsSkeleton, type GoalVitalRegion } from "./goal-masthead"; import { GoalWorkspacePanels } from "./goal-workspace-panels"; import { localizedGoalState, localizedSessionStatus, useWorkspaceI18n, type WorkspaceTranslate } from "./i18n"; import { MarkdownText } from "./markdown"; @@ -1090,6 +1091,11 @@ export function PersonalWorkspacePage({ && callbacks.onSteerConversationTurn ? runningMessage?.sourceTurnId : undefined; const composerBlocked = steering || (!steeringTurnId && (sending || conversationTurnRunning)); const quickPromptBlocked = steering || sending || conversationTurnRunning; + // The Goal overview is for reading; its composer folds to one line until asked for. + const [overviewComposerOpen, setOverviewComposerOpen] = useState(false); + useEffect(() => setOverviewComposerOpen(false), [selectedGoalId, selectedGoalTab]); + const composerCollapsed = Boolean(selectedGoal) && selectedGoalTab === "overview" && !overviewComposerOpen + && !composer.trim() && !actionDraft && !imageAttachments.length && !imageAttachmentError && !sending && !conversationTurnRunning; const managerChatItems = useMemo( () => items.filter((item) => item.kind === "message" || (item.kind === "proposal" && (managerSessionProposalIds.includes(item.proposal.previewId) @@ -1311,7 +1317,8 @@ export function PersonalWorkspacePage({ setActionDraft({ kind: "goal", goalId: null, goalTitle: "", agentId: selectedAgentId }); } - async function requestGoalLifecycle(goal: WorkspaceGoal, operation: GoalLifecycleOperation) { + /** `keepGoalSelected` reads the result back on the Goal page instead of returning to the directory. */ + async function requestGoalLifecycle(goal: WorkspaceGoal, operation: GoalLifecycleOperation, { keepGoalSelected = false } = {}) { setMobileSidebarOpen(false); const reasonByOperation: Record = { delete: "Deleted from the owner workspace", @@ -1355,7 +1362,7 @@ export function PersonalWorkspacePage({ projectionOwnedByApply = true; callbacks.onGoalActivationStateChange?.(goal.goalId, result.activationState); setActionFeedback(t("feedback.completed", { title: summaryByOperation[operation] })); - if (operation === "stop") selectGoal(null); + if (operation === "stop" && !keepGoalSelected) selectGoal(null); await reconcileStatus([goal.goalId]); return; } @@ -1378,6 +1385,7 @@ export function PersonalWorkspacePage({ if (proposal.reviewPlan?.interaction === "direct") { projectionOwnedByApply = true; await applyProposal(proposal, { + keepGoalSelected, lifecycleProjection: stopProjection ?? undefined, presentation: "feedback", }); @@ -1406,6 +1414,25 @@ export function PersonalWorkspacePage({ } } + function focusGoalRegion(region: GoalVitalRegion) { + if (!selectedGoal) return; + if (region === "usage") { + setSelection({ kind: "goal", item: selectedGoal }); + return; + } + setSelectedGoalTab(region === "tasks" ? "tasks" : "overview"); + window.requestAnimationFrame(() => { + const behavior = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth"; + const target = region === "attention" ? document.getElementById(goalOverviewAttentionId) : null; + if (target) { + target.scrollIntoView({ behavior, block: "nearest" }); + target.focus({ preventScroll: true }); + } else if (region !== "tasks") { + channelScrollRef.current?.scrollTo({ behavior, top: 0 }); + } + }); + } + function prepareScheduleDraft(kind: "heartbeat" | "monitor", goalId: string | null) { setActionDraft({ kind, goalId, goalTitle: workspaceGoals.find((goal) => goal.goalId === goalId)?.title ?? "", agentId: selectedAgentId }); setSelection(null); @@ -1459,6 +1486,7 @@ export function PersonalWorkspacePage({ async function applyProposal( proposal: WorkspaceActionPreview, options: { + keepGoalSelected?: boolean; lifecycleProjection?: GoalLifecycleProjection; presentation?: "drawer" | "feedback"; } = {}, @@ -1494,7 +1522,7 @@ export function PersonalWorkspacePage({ if (showDrawer) setSelection({ item: applied, kind: "proposal" }); setActionFeedback(t("feedback.completed", { title: proposal.title })); if (proposal.actionKind === "goal.lifecycle") { - if (proposal.lifecycleOperation === "stop" || proposal.lifecycleOperation === "delete") { + if (proposal.lifecycleOperation === "delete" || (proposal.lifecycleOperation === "stop" && !options.keepGoalSelected)) { selectGoal(null); } if (proposal.lifecycleOperation === "delete" && proposal.goalId) { @@ -1540,7 +1568,7 @@ export function PersonalWorkspacePage({ if (applied.actionKind === "todo.create" || applied.actionKind === "goal.create") { await callbacks.onRefresh?.(); } - if (applied.actionKind === "goal.lifecycle" && (applied.lifecycleOperation === "stop" || applied.lifecycleOperation === "delete")) { + if (applied.actionKind === "goal.lifecycle" && (applied.lifecycleOperation === "delete" || (applied.lifecycleOperation === "stop" && !options.keepGoalSelected))) { selectGoal(null); } if (applied.actionKind === "goal.lifecycle" && applied.lifecycleOperation === "delete" && applied.goalId) { @@ -1926,6 +1954,28 @@ export function PersonalWorkspacePage({ /> ) : null; + const composerSuggestions = !selectedWorkspaceRef && (!conversationOpen || !conversationMessages.length) ?
{ + if (event.key !== "Escape") return; + event.currentTarget.open = false; + event.currentTarget.querySelector("summary")?.focus(); + }}> + {locale === "zh-CN" ? "快捷提问" : "Suggestions"} + {selectedGoal ? ( +
+ + + + + +
+ ) : ( +
+ + + +
+ )} +
: null; return ( <> {selectedGoalId && selectedGoalTab === "chat" && !readOnly && selectedAgentId === "codex" && callbacks.onStartLoopX ? {locale === "zh-CN" ? "回到最新消息 ↓" : "Latest message ↓"} : null} - {!selectedWorkspaceRef && (!conversationOpen || !conversationMessages.length) ?
- {locale === "zh-CN" ? "快捷提问" : "Suggestions"} - {selectedGoal ? ( -
- - - - - -
- ) : ( -
- - - -
- )} -
: null} +
+ {composerCollapsed && selectedGoal ? <> + + : null} + {composerSuggestions} +
+ {!composerCollapsed ? <> {actionDraft ? setActionDraft(null)} onPreview={(request) => createPreview(request)} /> : null} {imageAttachments.length ?
{imageAttachments.map((attachment) => (
@@ -2251,6 +2301,7 @@ export function PersonalWorkspacePage({ : sending && !steeringTurnId ? (locale === "zh-CN" ? "正在回复 · 修改当前任务请使用“调整本轮”" : "Reply in progress · use Adjust turn to change the current task") : (locale === "zh-CN" ? "Enter 发送 · Shift+Enter 换行" : "Enter to send · Shift+Enter for a new line")}
: null} + : null} }
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 ead8f9135d..454889fb9a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -15,6 +15,9 @@ --pw-red-bg: #fcecea; --pw-green: #2e7d5b; --pw-green-bg: #e6f3ec; + --pw-hover: #f5f4f0; + --pw-track: #dcd9d0; + --pw-caution: #e39a1c; display: grid; grid-template-columns: 256px minmax(0, 1fr); min-height: 100vh; @@ -1569,6 +1572,9 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli --pw-red-bg: #fff0f0; --pw-green: #197342; --pw-green-bg: #edf9f1; + --pw-hover: #f5f5f5; + --pw-track: #d4d4d4; + --pw-caution: #f5a623; } .personal-workspace-shell[data-pw-theme="loopx"] .personal-workspace-sidebar, @@ -1742,6 +1748,9 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli --pw-red-bg: #ff9d8a; --pw-green: #141414; --pw-green-bg: #8fe6a4; + --pw-hover: #fff3b8; + --pw-track: #141414; + --pw-caution: #141414; } .personal-workspace-shell[data-pw-theme="brutal"] :focus-visible:not(textarea):not(input), .personal-settings-page[data-pw-theme="brutal"] :focus-visible:not(textarea):not(input) { outline-color: #141414; } diff --git a/apps/presentation/dashboard/src/styles.css b/apps/presentation/dashboard/src/styles.css index ace8b68bca..fea1c74a68 100644 --- a/apps/presentation/dashboard/src/styles.css +++ b/apps/presentation/dashboard/src/styles.css @@ -36,6 +36,12 @@ --leading-title: 2rem; --leading-display: 2.5rem; --leading-prose: 1.7; + /* Motion explains a state change; durations follow docs/development/design.md#motion. */ + --motion-fast: 120ms; + --motion-base: 200ms; + --motion-section: 280ms; + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --ease-standard: cubic-bezier(0.2, 0, 0, 1); font-family: var(--font-sans); } diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index b517d7b333..d7fa3e37f8 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -11,6 +11,7 @@ import type { AttentionDetails } from "../features/personal-workspace/attention- import { directoryStatusPayload, fetchWorkspaceDirectory, loadWorkspaceGoalSnapshots, workspaceReadPlan, type WorkspaceProgress, type WorkspaceLoadError, type WorkspaceReadScope } from "../data/workspace-progressive-status"; import { useEffect, useMemo, useRef, useState } from "react"; import { CircleAlert, Moon, RefreshCw, Sun } from "lucide-react"; +import type { DigestAttentionSummary } from "../../../../../loopx/control_plane/presentation/goal_workspace_digest.js"; import { dashboardRoute } from "../router"; import { @@ -382,14 +383,15 @@ function firstOpenTodo(todos?: TodoGroup | null) { function todosFromProjectAssetSummary( summary?: { items?: TodoItem[]; total?: number; open?: number; done?: number; advancement_done_count?: number; source_section?: string | null } | null, fallback?: TodoGroup | null, - _label = "todos", + role: "user" | "agent" = "agent", ): TodoGroup | null { - if (summary?.items?.length) { + if (summary?.items && (summary.items.length > 0 || (role === "user" && summary.open !== undefined))) { return { advancement_done_count: summary.advancement_done_count ?? fallback?.advancement_done_count, done_count: summary.done ?? summary.items.filter((item) => item.done).length, items: summary.items, - open_count: summary.open ?? summary.items.filter((item) => !item.done).length, + open_count: summary.open ?? fallback?.open_count + ?? (role === "user" ? undefined : summary.items.filter((item) => !item.done).length), total_count: summary.total ?? summary.items.length, }; } @@ -463,11 +465,13 @@ type PersonalGoalItem = { agentSentence: string; agentTodos: WorkspaceAgentTodo[]; doneTodoCount: number; + openTodoCount?: number; goalId: string; latestActivity?: string; needsYou?: string | null; needsYouActionKind?: string | null; needsYouBlocking?: boolean; + attentionSummary?: DigestAttentionSummary; needsYouTaskClass?: string | null; needsYouTodoId?: string | null; nextSentence: string; @@ -698,8 +702,8 @@ function personalVisibleAgentMessage(value: string) { function personalTodosForQueueItem(item: QueueItem, role: "user" | "agent") { const projectAsset = item.project_asset; return role === "user" - ? todosFromProjectAssetSummary(projectAsset?.user_todos, item.user_todos, "project_asset.user_todos") - : todosFromProjectAssetSummary(projectAsset?.agent_todos, item.agent_todos, "project_asset.agent_todos"); + ? todosFromProjectAssetSummary(projectAsset?.user_todos, item.user_todos, role) + : todosFromProjectAssetSummary(projectAsset?.agent_todos, item.agent_todos, role); } function personalTodoText(todo: TodoItem) { @@ -994,10 +998,20 @@ function buildPersonalHomeModel( ); const usageById = shareUsageById(payload.usage_summary); const agentRows = buildAgentManagementRows(rows, payload.todo_index, payload.agent_management_projection); + const attentionSummaries = new Map(); const attentionHistory = payload.attention_queue.items.flatMap((item, sourceOrder) => { if (stoppedGoalIds.has(item.goal_id)) return []; const blocking = ["user_or_controller", "controller"].includes(item.waiting_on); - return (personalTodosForQueueItem(item, "user")?.items ?? []) + const group = personalTodosForQueueItem(item, "user"); + const listedOpen = group?.items.filter((todo) => !todo.done).length ?? 0; + const open = group?.open_count; + const count = typeof open === "number" && Number.isSafeInteger(open) && open >= listedOpen ? open : null; + attentionSummaries.set(item.goal_id, { + blocking: count === null ? null : blocking ? count : 0, + optional: count === null ? null : blocking ? 0 : count, + coverage: count === null ? "unknown" : count > listedOpen ? "partial" : "complete", + }); + return (group?.items ?? []) .map((todo, todoOrder): PersonalNeedsYouItem & { sourceOrder: number; todoOrder: number; projectedDone: boolean } => ({ projectedDone: todo.done, details: attentionDetailsFromSnapshot(todo, item.user_todos?.items ?? [], item.goal_id), @@ -1014,8 +1028,17 @@ function buildPersonalHomeModel( }); const projectedUserTodos = attentionHistory.filter((todo) => !todo.projectedDone); const projectedGoalIds = new Set(projectedUserTodos.map((todo) => todo.goalId)); + for (const [goalId, summary] of attentionSummaries) { + if ((summary.blocking ?? 0) + (summary.optional ?? 0) > 0) projectedGoalIds.add(goalId); + } const pendingOperatorGates = rows.flatMap((row, rowOrder) => { if (stoppedGoalIds.has(row.goal.id) || projectedGoalIds.has(row.goal.id) || !personalGoalHasPendingOperatorGate(row)) return []; + // An independently observed run gate is one decision, even without a Todo. + // An unreadable User Todo group keeps its unknown total rather than hiding it. + const summary = attentionSummaries.get(row.goal.id); + if (summary?.coverage === "complete") attentionSummaries.set(row.goal.id, { + ...summary, blocking: (summary.blocking ?? 0) + 1, + }); return [{ details: attentionDetails({ task_class: "user_gate", status: "open", note: row.latestRun?.operator_gate?.reason_summary }), actionKind: "gate.resolve", @@ -1090,6 +1113,7 @@ function buildPersonalHomeModel( boundHostSurfaces: Array.from(new Set((goal.coordination?.thread_agent_bindings ?? []) .flatMap((binding) => binding.host_surface ? [binding.host_surface] : []))), doneTodoCount: agentTodoFacts.doneTodoCount, + openTodoCount: row.queueItem?.agent_todos?.open_count ?? row.queueItem?.project_asset?.agent_todos?.open, hostThreadActivity: goal.host_thread_activity ? { completeness: goal.host_thread_activity.completeness, threads: goal.host_thread_activity.threads.map((thread) => ({ @@ -1104,6 +1128,9 @@ function buildPersonalHomeModel( needsYou, needsYouActionKind: needsYouTodo?.actionKind ?? null, needsYouBlocking: needsYouTodo?.blocking ?? false, + attentionSummary: goal.activation_state === "stopped" + ? { blocking: 0, optional: 0, coverage: "complete" } satisfies DigestAttentionSummary + : attentionSummaries.get(goal.id), needsYouTaskClass: needsYouTodo?.taskClass ?? null, needsYouTodoId: needsYouTodo?.todoId ?? null, nextSentence, diff --git a/docs/development/assets/goal-workspace/attention-after.png b/docs/development/assets/goal-workspace/attention-after.png new file mode 100644 index 0000000000..109e5071f7 Binary files /dev/null and b/docs/development/assets/goal-workspace/attention-after.png differ diff --git a/docs/development/assets/goal-workspace/attention-before.png b/docs/development/assets/goal-workspace/attention-before.png new file mode 100644 index 0000000000..848eff7779 Binary files /dev/null and b/docs/development/assets/goal-workspace/attention-before.png differ diff --git a/docs/development/assets/goal-workspace/before-desktop.png b/docs/development/assets/goal-workspace/before-desktop.png new file mode 100644 index 0000000000..e01c7aa7e7 Binary files /dev/null and b/docs/development/assets/goal-workspace/before-desktop.png differ diff --git a/docs/development/assets/goal-workspace/before-phone.png b/docs/development/assets/goal-workspace/before-phone.png new file mode 100644 index 0000000000..9478f979cc Binary files /dev/null and b/docs/development/assets/goal-workspace/before-phone.png differ diff --git a/docs/development/assets/goal-workspace/overview-desktop.png b/docs/development/assets/goal-workspace/overview-desktop.png new file mode 100644 index 0000000000..5e398eb00f Binary files /dev/null and b/docs/development/assets/goal-workspace/overview-desktop.png differ diff --git a/docs/development/assets/goal-workspace/overview-phone.png b/docs/development/assets/goal-workspace/overview-phone.png new file mode 100644 index 0000000000..89dcaf9c1c Binary files /dev/null and b/docs/development/assets/goal-workspace/overview-phone.png differ diff --git a/docs/development/design.md b/docs/development/design.md index 8124361698..02214919a9 100644 --- a/docs/development/design.md +++ b/docs/development/design.md @@ -353,6 +353,108 @@ Do not mix marketing pills and application squares in the same control group. Do not shrink diagrams or code until they become illegible. Reflow or enable bounded horizontal scrolling. +## Target Compositions + +The rules above say what to avoid. This section says what a finished +operator surface looks like, so a change can be judged against a composition +rather than only against a list of prohibitions. Each pattern names the +mechanism; copy the mechanism, not another product's styling. + +### Goal workspace: a document, not a card grid + +From top to bottom, one reading column with an optional right rail: + +1. **Masthead.** Breadcrumb, title, one context line (agents, repository) and + the single consequential control (Stop / Resume). Nothing else competes. +2. **Vitals.** At most six stats in one row. The value carries the weight + (heading size, tabular numerals); the label and its qualifier sit beneath + in one caption line. No cell borders: spacing separates stats, one hairline + separates the row from the page. Unknown is an em dash with its reason, + never zero. +3. **One hero.** On the Overview, the Goal's objective is set as heading-size + prose without a card around it, followed by a segmented criteria meter and + a hairline checklist. Everything below the hero recedes: smaller headings, + ghost controls, a hairline instead of another bordered panel. +4. **Owner rail.** What needs the owner sits beside the hero as a hairline + list: glyph, the decision in owner language, relative age, an arrow that + opens it in place. A quiet Goal shows no empty rail. +5. **Folded composer.** Reading views fold the composer to one line that + expands on request; conversation views keep it open. + +The browser scenario `goal-showcase` +(`examples/personal-workspace-browser/goal-showcase.mjs`) renders this +composition on the synthetic showcase workspace +(`examples/personal-workspace-browser/showcase-workspace.mjs`): two agents, +measured usage, an observed live turn, owner decisions, a partly verified +contract and a delivery map. Take before/after screenshots from that state, +not from placeholder fixtures. + +The same public synthetic workspace illustrates the composition below. The +before view is the earlier masthead proposal; the after view uses the packaged +Goal overview with the document hero, owner rail and folded composer. These +are composition references, not installed-App or model-acceptance evidence. + +| View | Before | Proposed composition | +| --- | --- | --- | +| Desktop | ![Earlier desktop composition](assets/goal-workspace/before-desktop.png) | ![Goal overview on desktop](assets/goal-workspace/overview-desktop.png) | +| Phone | ![Earlier phone composition](assets/goal-workspace/before-phone.png) | ![Goal overview on phone](assets/goal-workspace/overview-phone.png) | + +### Status glyphs + +One glyph per state across lists, vitals and maps; color is the second signal. + +| State | Glyph | Color | +| --- | --- | --- | +| Settled / quiet | filled dot | track neutral | +| Waiting or stopped | hollow ring | muted | +| Observed live | ring with a core and a rotating arc | state color | +| Verified | check in circle | green | +| Failing | cross in circle | red | +| Not yet verified | dashed circle | faint | +| Owner decision | hand | caution | + +The rotating arc requires an observed active turn (see "Liveness comes from +observation"). Reduced motion stops the arc; the ring and core still read live. + +### States that are designed, not defaulted + +- **Loading** holds the final geometry with a quiet skeleton; a region does + not collapse to one spinner and then jump. +- **Region failure** is one announced line (icon, plain cause, how to retry) + sized to its content. Reserve banners for a whole-surface failure. +- **Unknown** stays visible as "—" with its reason; it never becomes zero or + disappears. + +### Motion vocabulary + +Tokens live in the Dashboard root: `--motion-fast` (120ms controls), +`--motion-base` (200ms state swaps), `--motion-section` (280ms panels), +`--ease-out` and `--ease-standard`. + +- A changed value swaps with a short fade and 3px rise; a view panel enters + with opacity and 4px. +- Values use the short swap transition while their text changes directly to + the new observation. Do not interpolate task counts, decisions, time or money: + intermediate text would invent a state. Goal and usage-window changes, unknown + values and reduced motion retain the same exact observation semantics. +- New list rows enter with opacity and a 4px rise; row arrows nudge 2px on hover. +- Infinite motion is reserved for an observed live state or an in-flight read. + +### Reference research + +References inspected in October 2026: the LobeHub creator walkthrough shows +composition; the Up.computer video was sampled for expansion and status changes; +Linear documents its review interaction; Agent Dashboard describes planned +behavior in a design specification. These are research inputs, not approved +design sources or evidence that LoopX has shipped the same interaction. + +| Reference | Mechanism adopted | Not adopted | +| --- | --- | --- | +| LobeHub Goal and task detail ([source](https://github.com/lobehub/lobehub), [public walkthrough](https://x.com/arvin17x/status/2108966992149713125)) | Document layout; value-over-label stat row; objective as body prose; one glyph family with an animated running ring; four grey text levels; inline expansion of a failed row | Dark-first chrome; graph as a default view at high fan-out | +| Linear Reviews ([official interaction](https://linear.app/docs/diffs), [creator post](https://x.com/artman/status/2107130290053447824)) | Quiet segmented view switch; hairline file list with compact counts; generous reading column beside dense data | Code-review specific structure | +| Up.computer ([launch video](https://x.com/krl_grn/status/2107478644226396339)) | Status word as a quiet prefix in a list ("Working"); collapsible "worked for" and tool-call steps; composer with model and permission inline | Parallel chat panes as the primary layout | +| Agent Dashboard design spec ([source](https://github.com/wildware-uk/agent-dashboard/blob/main/docs/superpowers/specs/2026-08-25-agent-dashboard-design.md)) | New items animate in; an "N new" affordance instead of moving the reader; pending decisions pinned above the feed | Name-hashed avatar colors as identity | + ## UI Reproduction Workflow For feature development, carry one user task through the @@ -406,3 +508,15 @@ parity is required. Framework migration alone must not alter the UI. - [ ] English and Chinese content remain usable. - [ ] Screenshots are provided for first-screen or fidelity-sensitive changes. - [ ] No private data or local paths enter the UI or screenshots. + +### Craft review + +Run these against before/after screenshots of the showcase workspace, not a +placeholder fixture: + +- [ ] The viewport has one hero; secondary regions visibly recede. +- [ ] No bordered card exists only to group; a hairline or spacing would do. +- [ ] Loading, empty, unknown and failed states are designed and sized to content. +- [ ] Changed values and entering rows move once, by token; idle content is still. +- [ ] Glyphs follow the status glyph family; color is never the only signal. +- [ ] Touched stylesheets use theme tokens, not new raw hex values. diff --git a/docs/development/frontend-delivery.md b/docs/development/frontend-delivery.md index badc953a54..127ec587d5 100644 --- a/docs/development/frontend-delivery.md +++ b/docs/development/frontend-delivery.md @@ -77,6 +77,12 @@ and the state readback plus tested revision and limits. Keep private messages, local paths and raw run logs out. The existing public first-screen preview gate still applies; do not add user approvals for routine implementation decisions. +For Goal workspace composition, render before and after on the synthetic +showcase workspace (`LOOPX_PERSONAL_WORKSPACE_SCENARIO=goal-showcase node +examples/personal-workspace-browser-smoke.mjs`) and answer the +[craft review](design.md#craft-review) from those screenshots. Placeholder +fixtures prove behavior; they are not a basis for judging composition. + **Evaluation cadence:** ordinary work runs offline regressions and affected browser/backend checks. Paid model interpretation and review-decision probes belong to release-candidate qualification, with failures/skips retained. A @@ -86,7 +92,9 @@ probe is not a pass. See [release readiness](../product/release-readiness.md). 中文要点:先定义用户想完成的事,再比较最短合法路径;复用已有授权与状态归属, 减少重填、重复确认和页面跳转。以真实构建页面、代表性数据和状态回读验收,区分 模拟边界、打包验证和安装生效。评审先挑战交互成本与证据,再解释代码;平时不跑 -付费模型评测,不用测试数量或 PR 合并代替用户任务完成。 +付费模型评测,不用测试数量或 PR 合并代替用户任务完成。Goal 工作区的构图改动用 +`goal-showcase` 合成展示数据截取前后对比图,并按 design.md 的 Craft review 逐项 +回答;占位 fixture 只证明行为,不作为判断构图的依据。 ## Source development and updates diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index a932b22ff9..4b301ad669 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -62,6 +62,8 @@ import { newestDraftScenario } from "./personal-workspace-browser/newest-draft.m import { conversationInputScenario } from "./personal-workspace-browser/conversation-input.mjs"; import { goalActivityScenario } from "./personal-workspace-browser/goal-activity.mjs"; import { goalWorkMapScenario } from "./personal-workspace-browser/goal-work-map.mjs"; +import { goalMastheadScenario } from "./personal-workspace-browser/goal-masthead.mjs"; +import { goalShowcaseScenario } from "./personal-workspace-browser/goal-showcase.mjs"; import { stewardGroupTriggerScenario } from "./personal-workspace-browser/steward-group-trigger.mjs"; @@ -82,6 +84,8 @@ scenarioCatalog.push(monitorReadbackScenario); scenarioCatalog.push(blockedReasonReadbackScenario); scenarioCatalog.push(turnStepsScenario); scenarioCatalog.push(goalWorkMapScenario); +scenarioCatalog.push(goalMastheadScenario); +scenarioCatalog.push(goalShowcaseScenario); scenarioCatalog.push(performanceDiagnosisScenario); scenarioCatalog.push(blockedNoticeSettingsScenario); scenarioCatalog.push(nativeChildActivityScenario); diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 55ff73ac3d..788d3c212a 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -491,6 +491,8 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true // Applied User action outcomes, so the needs-you projection reads back like canonical status. userActionOutcomes: new Map(), hostThreadActivity: {}, + /** Scenario hook applied to every status payload after the shared fixture rules. */ + transformStatus: null, answerForMessage: null, loopxModeRequests: [], get larkConnections() { return runtime.larkConnections; }, @@ -762,6 +764,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true const goal = fixture.run_history.goals.find((item) => item.id === goalId); if (goal) goal.host_thread_activity = activity; } + state.transformStatus?.(fixture); fixture.run_history.goals = fixture.run_history.goals.filter(goal => !state.deletedGoalIds.has(goal.id)); const goalActivationScope = new URL(route.request().url()).searchParams.get("goal_activation"); const isActiveScope = goalActivationScope === "active"; diff --git a/examples/personal-workspace-browser/goal-activity.mjs b/examples/personal-workspace-browser/goal-activity.mjs index 0050ee620a..6d4e71c4fb 100644 --- a/examples/personal-workspace-browser/goal-activity.mjs +++ b/examples/personal-workspace-browser/goal-activity.mjs @@ -141,10 +141,10 @@ export const goalActivityScenario = { ); await page.screenshot({ path: resolve(outputDir, "goal-activity-sidebar.png"), animations: "disabled" }); await running.locator(".personal-goal-link").click(); - await page.locator(".personal-channel-activity", { hasText: "执行中" }).waitFor(); + await page.getByTestId("goal-vitals").locator(".goal-vital-value", { hasText: "执行中" }).first().waitFor(); await page.screenshot({ path: resolve(outputDir, "goal-activity-running-goal.png"), animations: "disabled" }); await queued.locator(".personal-goal-link").click(); - await page.locator(".personal-channel-activity", { hasText: "已安排" }).waitFor(); + await page.getByTestId("goal-vitals").locator(".goal-vital-value", { hasText: "已安排" }).first().waitFor(); await page.setViewportSize({ width: 390, height: 844 }); await page.screenshot({ path: resolve(outputDir, "goal-activity-mobile.png"), animations: "disabled" }); coverageEntries.push(...await live.close()); diff --git a/examples/personal-workspace-browser/goal-attention-fixture.py b/examples/personal-workspace-browser/goal-attention-fixture.py new file mode 100644 index 0000000000..c836502995 --- /dev/null +++ b/examples/personal-workspace-browser/goal-attention-fixture.py @@ -0,0 +1,42 @@ +"""Exercise the real status producer against disposable synthetic Goal state.""" +from __future__ import annotations + +import json +from pathlib import Path +from tempfile import TemporaryDirectory + +from loopx.status import collect_status + + +def main() -> None: + with TemporaryDirectory(prefix="loopx-attention-ui-") as directory: + root = Path(directory) + state = root / "GOAL.md" + state.write_text( + "---\nstatus: waiting\n---\n# Synthetic decision scope\n\n## User Todo\n\n" + + "\n".join( + f"- [ ] Review synthetic decision {index}.\n" + f" " + for index in range(21) + ) + + "\n\n## Agent Todo\n\n- [ ] Continue after the owner decision.\n", + encoding="utf-8", + ) + registry = root / "registry.json" + registry.write_text(json.dumps({"schema_version": 1, "common_runtime_root": str(root / "runtime"), + "goals": [{"id": "progress-projection", "repo": str(root), "state_file": state.name, + "domain": "general", "status": "active", "authority_sources": [], + "adapter": {"kind": "test_v0", "status": "connected-read-only"}}]}), encoding="utf-8") + payload = collect_status(registry_path=registry, runtime_root_override=str(root / "runtime"), + scan_roots=[], limit=5, include_public_boundary_scan=False) + item = next(item for item in payload["attention_queue"]["items"] if item["goal_id"] == "progress-projection") + assert item["user_todos"]["open_count"] == 21 + assert len(item["user_todos"]["items"]) == 12 + assert item["project_asset"]["user_todos"]["open"] == 21 + assert len(item["project_asset"]["user_todos"]["items"]) == 3 + print(json.dumps(item)) + + +if __name__ == "__main__": + main() diff --git a/examples/personal-workspace-browser/goal-masthead.mjs b/examples/personal-workspace-browser/goal-masthead.mjs new file mode 100644 index 0000000000..fbcff287d6 --- /dev/null +++ b/examples/personal-workspace-browser/goal-masthead.mjs @@ -0,0 +1,256 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { resolve } from "node:path"; +import { resolveTestPython } from "../../scripts/test-python.mjs"; +import { outputDir, repoRoot } from "./fixture.mjs"; +import { openWorkspacePage } from "./scenario-context.mjs"; + +// The Goal masthead keeps identity, the stop control and six typed vitals on +// every Goal view. Expected values follow the vital semantics, not the render: +// the fixture Goal reports the same 4087 completed advancement Todos as the +// completed-history read, has three open and two deferred advancement Todos +// plus a done monitor, two blocking decisions, a 7-day usage window and one +// host-recorded open turn. +const goalId = "progress-projection"; +const title = "Quarterly cash-flow study"; +const minutesAgo = (minutes) => new Date(Date.now() - minutes * 60_000).toISOString(); + +const acceptance = { + schema_version: "goal_acceptance_observation_projection_v0", goal_id: goalId, read_only: true, acceptance_assessed: false, + coverage: "partial", missing_sources: [], truncated: false, historical_progress: [], acceptance_gaps: [], guards: [], + next_action: null, next_action_source: null, + goal_acceptance_contract: { + enabled: true, revision: 2, digest: `sha256:${"a".repeat(64)}`, objective: "Reconcile operating cash flow for three fiscal years from primary filings.", + scope: { kind: "all_advancement" }, non_goals: [], held_todo_ids: [], status: "partial", + criteria: [ + { id: "sources", description: "Every figure cites a primary filing" }, + { id: "reconcile", description: "Totals reconcile to the reported statements" }, + { id: "review", description: "An independent reviewer signs off" }, + ], + tasks: [], + verification: { operation_id: "verify-1", contract_revision: 2, contract_digest: `sha256:${"a".repeat(64)}`, todo_id: null, + results: [{ criterion_id: "sources", passed: true, exit_code: 0 }, { criterion_id: "reconcile", passed: false, exit_code: 1 }] }, + }, +}; + +function populate(api, { usage = true, hostTurn = true } = {}) { + api.hostThreadActivity = hostTurn ? { [goalId]: { completeness: "complete", threads: [ + { agent_id: "codex", host_surface: "codex-app", state: "turn_open", last_event_at: minutesAgo(2) }, + ] } } : {}; + api.transformStatus = (fixture) => { + const goal = fixture.run_history.goals.find((item) => item.id === goalId); + if (goal) Object.assign(goal, { display_name: title, acceptance_observation: acceptance }); + const queueItem = fixture.attention_queue.items.find((item) => item.goal_id === goalId); + if (queueItem?.project_asset?.agent_todos) queueItem.project_asset.agent_todos.advancement_done_count = 4087; + if (queueItem && goal?.activation_state !== "stopped") { + queueItem.waiting_on = "user_or_controller"; + queueItem.user_todos = { source_section: "User Todo", open_count: 2, total_count: 2, items: [ + { done: false, status: "open", index: 0, role: "user", task_class: "user_gate", goal_id: goalId, todo_id: "masthead-gate-scope", text: "Approve the FY2023 restatement treatment", updated_at: minutesAgo(190) }, + { done: false, status: "open", index: 1, role: "user", task_class: "user_gate", goal_id: goalId, todo_id: "masthead-gate-source", text: "Choose the segment disclosure source", updated_at: minutesAgo(25) }, + ] }; + } + if (usage) { + fixture.usage_summary.goals.push({ goal_id: goalId, cost_usd_24h: 3.1, cost_usd_7d: 18.4, duration_ms_24h: 2_400_000, duration_ms_7d: 11_520_000 }); + } + }; +} + +async function openGoal(page, name) { + await page.locator(".personal-goal-row").filter({ has: page.locator("strong", { hasText: name }) }).first().locator(".personal-goal-link").click(); + await page.getByTestId("goal-vitals").waitFor({ state: "visible", timeout: 15_000 }); +} + +async function vital(page, label) { + const cell = page.getByTestId("goal-vitals").locator(".goal-vital").filter({ has: page.locator(".goal-vital-label", { hasText: label }) }); + return { cell, value: (await cell.locator(".goal-vital-value").innerText()).trim(), sub: (await cell.locator(".goal-vital-sub").allInnerTexts()).join(" ").trim() }; +} + +export const goalMastheadScenario = { + id: "goal-masthead", + async run({ browser, collectCoverage, url }) { + const coverageEntries = []; + const session = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width: 1440, height: 900 }, beforeGoto: (api) => populate(api) }); + const { api, page } = session; + const actionRequests = []; + page.on("request", (request) => { if (new URL(request.url()).pathname.startsWith("/api/actions/")) actionRequests.push(request.url()); }); + try { + await openGoal(page, title); + assert.equal(await page.locator(".personal-channel-title h1").innerText(), title); + assert.match(await page.locator(".goal-masthead-breadcrumb").innerText(), /工作台\s*Goals\s*Quarterly cash-flow study/); + assert.equal(await page.getByTestId("goal-vitals").locator(".goal-vital").count(), 6, "At most six vitals"); + + const state = await vital(page, "状态"); + assert.equal(state.value, "等你", "A waiting decision leads the state, as in the Goal list"); + assert.match(state.sub, /另有执行进行中/, "The observed turn stays visible beside the decision"); + assert.equal(await state.cell.locator(".goal-vital-dot.is-live").count(), 1, "A fresh host-recorded turn is live"); + const work = await vital(page, "工作"); + assert.equal(work.value, "4087/4092", "done from the owner count; open counts open and deferred work and skips monitors"); + assert.match(work.sub, /5 项未完成/); + const needs = await vital(page, "等你决定"); + assert.equal(needs.value, "2"); + assert.ok(await needs.cell.evaluate((node) => node.classList.contains("is-danger")), "Waiting decisions take the danger tone"); + assert.match(needs.sub, /最早 3\s*小时前/); + const agentTime = await vital(page, "Agent 用时"); + const cost = await vital(page, "成本"); + assert.deepEqual([agentTime.value, agentTime.sub, cost.value, cost.sub], ["3.2h", "近 7 天", "$18.40", "近 7 天"], "Agent time and Cost share the widest window"); + const last = await vital(page, "最近活动"); + assert.match(last.value, /2\s*分钟前/); + assert.equal(last.sub, "Codex App"); + + await page.locator(".personal-goal-tabs button", { hasText: "概览" }).click(); + const direction = page.locator(".goal-overview-direction"); + assert.match(await direction.innerText(), /Reconcile operating cash flow[\s\S]*1\/3 项条件已核验/); + assert.equal(await direction.locator(".goal-overview-criteria .is-failing").count(), 1); + assert.equal(await page.locator(".goal-overview-usage").count(), 0, "Usage moved to the vitals strip"); + await page.screenshot({ path: resolve(outputDir, "goal-masthead-overview.png"), animations: "disabled" }); + + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.evaluate(() => { + const target = document.getElementById("goal-overview-attention"); + const scroll = target.scrollIntoView.bind(target); + target.scrollIntoView = (options) => { target.dataset.scrollBehavior = options?.behavior ?? "auto"; scroll(options); }; + }); + await needs.cell.click(); + await page.waitForFunction(() => document.activeElement?.id === "goal-overview-attention"); + assert.equal(await page.locator("#goal-overview-attention").getAttribute("data-scroll-behavior"), "auto", "Reduced motion reaches the decision directly without a scrolling animation"); + await page.emulateMedia({ reducedMotion: null }); + await work.cell.click(); + await page.locator('.personal-goal-tabs button[aria-current="page"]', { hasText: "任务" }).waitFor(); + const openLane = page.locator(".personal-task-lane").filter({ has: page.locator("header strong", { hasText: "待执行 / 进行中" }) }); + assert.equal(await openLane.locator("header > span").innerText(), "5", "The board lists the same open work the strip counts"); + await page.getByTestId("completed-task-lane").locator("header", { hasText: "4087" }).waitFor({ timeout: 10_000 }); + await page.locator(".personal-goal-tabs button", { hasText: "对话" }).click(); + await page.getByTestId("goal-vitals").waitFor({ state: "visible" }); + assert.equal(actionRequests.length, 0, "Opening the Goal, switching views and focusing vitals sends no action request"); + + await page.locator(".personal-goal-tabs button", { hasText: "概览" }).click(); + const stop = page.locator(".goal-lifecycle-trigger"); + await stop.click(); + const dialog = page.getByRole("dialog", { name: "停止这个 Goal?" }); + await dialog.waitFor(); + assert.match(await dialog.innerText(), /已在运行的工具调用会执行完[\s\S]*只有恢复才能重新获得调度资格/); + await page.screenshot({ path: resolve(outputDir, "goal-masthead-stop-confirm.png"), animations: "disabled" }); + await page.keyboard.press("Escape"); + await dialog.waitFor({ state: "detached" }); + assert.ok(await stop.evaluate((node) => node === document.activeElement), "Escape returns focus to the trigger"); + assert.equal(actionRequests.length, 0, "Cancelling sends nothing"); + + await stop.click(); + await dialog.getByRole("button", { name: "停止 Goal" }).click(); + await page.locator(".goal-lifecycle-trigger", { hasText: "恢复" }).waitFor({ timeout: 10_000 }); + assert.equal(api.goalActivationStates.get(goalId), "stopped"); + assert.equal(await page.locator(".personal-channel-title h1").innerText(), title, "The page stays on the Goal to read back the stop"); + const stopping = await vital(page, "状态"); + assert.deepEqual([stopping.value, stopping.sub], ["正在停止", "Turn 收尾中"], "An observed open turn finishes after the stop"); + assert.equal(await stopping.cell.locator(".goal-vital-dot.is-live").count(), 0); + assert.equal(api.actionApplies.length, 1, "One confirmed stop applies once"); + await page.screenshot({ path: resolve(outputDir, "goal-masthead-stopping.png"), animations: "disabled" }); + + api.hostThreadActivity = {}; + await page.reload({ waitUntil: "networkidle" }); + await page.getByTestId("goal-vitals").waitFor({ state: "visible", timeout: 15_000 }); + assert.equal(await page.locator(".personal-channel-title h1").innerText(), title, "The stopped Goal stays selected across reload"); + assert.equal((await vital(page, "状态")).value, "已停止", "Without an observed turn the Goal reads stopped"); + assert.equal((await vital(page, "等你决定")).value, "0", "A stopped Goal leaves active attention"); + + await page.locator(".goal-lifecycle-trigger", { hasText: "恢复" }).click(); + assert.equal(await page.getByRole("dialog", { name: "恢复这个 Goal?" }).count(), 0, "Resume uses the owner's reviewed preview, not a second confirmation"); + await page.getByRole("button", { name: "恢复 Goal", exact: true }).click(); + await page.locator(".goal-lifecycle-trigger", { hasText: "停止" }).waitFor({ timeout: 10_000 }); + assert.equal(api.goalActivationStates.get(goalId), "active"); + await page.getByRole("button", { name: /关闭详情/ }).click(); + await page.locator("[data-context-drawer]").waitFor({ state: "detached" }); + await page.locator(".personal-goal-tabs button", { hasText: "概览" }).click(); + + await page.setViewportSize({ width: 1024, height: 768 }); + await page.screenshot({ path: resolve(outputDir, "goal-masthead-1024.png"), animations: "disabled" }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.screenshot({ path: resolve(outputDir, "goal-masthead-phone.png"), animations: "disabled" }); + await page.locator(".personal-goal-tabs button", { hasText: "对话" }).click(); + await page.screenshot({ path: resolve(outputDir, "goal-masthead-phone-chat.png"), animations: "disabled" }); + const overflow = await page.getByTestId("goal-vitals").evaluate((strip) => strip.scrollWidth - strip.clientWidth); + assert.ok(overflow <= 1, "The phone grid fits without horizontal scrolling"); + assert.equal(session.errors.length, 0, session.errors.join(" | ")); + coverageEntries.push(...await session.close()); + } catch (error) { + await page.screenshot({ path: resolve(outputDir, "goal-masthead-failed.png") }); + await session.close(); + throw error; + } + + const quiet = await openWorkspacePage(browser, url, { collectCoverage, beforeGoto: (api) => populate(api, { usage: false, hostTurn: false }) }); + try { + await openGoal(quiet.page, title); + const agentTime = await vital(quiet.page, "Agent 用时"); + const cost = await vital(quiet.page, "成本"); + assert.deepEqual([agentTime.value, cost.value, cost.sub], ["—", "—", "未采集"], "Absent usage is unknown, never $0.00"); + const last = await vital(quiet.page, "最近活动"); + assert.deepEqual([last.value, last.sub], ["—", "暂无观测到的活动"], "A claim or Todo write is not activity"); + assert.notEqual((await vital(quiet.page, "状态")).value, "执行中"); + await quiet.page.screenshot({ path: resolve(outputDir, "goal-masthead-unmeasured.png"), animations: "disabled" }); + await quiet.page.evaluate(() => localStorage.setItem("loopx-pw-locale", "en")); + await quiet.page.reload({ waitUntil: "networkidle" }); + await quiet.page.getByTestId("goal-vitals").waitFor({ state: "visible", timeout: 15_000 }); + assert.match(await quiet.page.getByTestId("goal-vitals").innerText(), /State[\s\S]*Work[\s\S]*Needs you[\s\S]*Agent time[\s\S]*Cost[\s\S]*Last activity/); + await quiet.page.screenshot({ path: resolve(outputDir, "goal-masthead-en.png"), animations: "disabled" }); + coverageEntries.push(...await quiet.close()); + } catch (error) { + await quiet.page.screenshot({ path: resolve(outputDir, "goal-masthead-unmeasured-failed.png") }); + await quiet.close(); + throw error; + } + // The real producer bounds the queue to 12 and its overview to three while + // retaining 21 open decisions. Pass its payload through the shipped parser + // and adapter, rather than manufacturing a complete three-item queue. + const fixtureEnv = { ...process.env, LOOPX_USAGE_PING: "0" }; + if (!["LOOPX_TEST_PYTHON", "LOOPX_PYTHON_BIN", "LOOPX_PYTHON"].some(key => process.env[key])) fixtureEnv.PYTHONPATH = repoRoot; + const native = JSON.parse(execFileSync(resolveTestPython(), [ + resolve(repoRoot, "examples/personal-workspace-browser/goal-attention-fixture.py"), + ], { cwd: repoRoot, env: fixtureEnv, encoding: "utf8", timeout: 30_000 })); + for (const locale of ["zh-CN", "en"]) for (const mode of ["bounded", "optional", "empty", "unknown"]) { + const ui = await openWorkspacePage(browser, url, { + collectCoverage, + viewport: { width: locale === "en" ? 390 : 1512, height: 982 }, + beforeGoto: async (api, page) => { + await page.addInitScript(value => localStorage.setItem("loopx-pw-locale", value), locale); + populate(api); + const previous = api.transformStatus; + api.transformStatus = (fixture) => { + previous(fixture); + const queue = fixture.attention_queue.items.find(item => item.goal_id === goalId); + Object.assign(queue, structuredClone(native)); + if (mode === "optional") queue.waiting_on = "agent"; + if (mode === "empty") { + queue.user_todos.items = []; + queue.project_asset.user_todos.items = []; + } + if (mode === "unknown") { + delete queue.user_todos.open_count; + delete queue.project_asset.user_todos.open; + } + }; + }, + }); + try { + if (locale === "en") await ui.page.locator(".personal-mobile-menu").click(); + await openGoal(ui.page, title); + const needs = await vital(ui.page, locale === "en" ? "Needs you" : "等你决定"); + assert.equal(needs.value, mode === "unknown" ? "—" : mode === "optional" ? "0" : "21"); + assert.match(needs.sub, locale === "en" ? /oldest unknown/ : /最早时间未知/); + assert.match(needs.sub, new RegExp(`${mode === "empty" ? 0 : 3} ${locale === "en" ? "listed" : "项"}`)); + if (mode === "optional") assert.match(needs.sub, locale === "en" ? /21 optional/ : /21 项可选/); + if (mode === "unknown") assert.doesNotMatch(needs.sub, /Nothing waiting|暂无等待/); + await ui.page.getByRole("navigation", { name: /^(Goal 视图|Goal view)$/ }).getByRole("button", { name: /^(概览|Overview)$/ }).click(); + const rail = ui.page.locator(".goal-overview-attention"); + assert.equal(await rail.locator(".goal-overview-count").textContent(), mode === "unknown" ? "—" : mode === "optional" ? "0" : "21", "Rail and strip retain the same owner total"); + assert.match(await rail.textContent(), locale === "en" ? /oldest unknown/ : /最早时间未知/); + await rail.locator(".goal-overview-more").waitFor({ state: "visible" }); + await ui.page.screenshot({ path: resolve(outputDir, `goal-attention-${mode}-${locale}.png`), animations: "disabled" }); + assert.equal(ui.errors.length, 0, ui.errors.join(" | ")); + assert.equal(ui.api.turnRequests.length, 0, "Reading attention never starts a Turn"); + assert.equal(ui.api.actionApplies.length, 0, "Reading attention never changes owning state"); + } finally { coverageEntries.push(...await ui.close()); } + } + return { coverageEntries, note: "masthead, stop readback, unmeasured usage and real-producer bounded attention" }; + }, +}; diff --git a/examples/personal-workspace-browser/goal-showcase.mjs b/examples/personal-workspace-browser/goal-showcase.mjs new file mode 100644 index 0000000000..d386328db5 --- /dev/null +++ b/examples/personal-workspace-browser/goal-showcase.mjs @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import { resolve } from "node:path"; +import { outputDir } from "./fixture.mjs"; +import { openWorkspacePage } from "./scenario-context.mjs"; +import { installShowcaseWorkspace, openShowcaseGoal } from "./showcase-workspace.mjs"; + +// Visual-quality contract for the Goal overview, rendered on the showcase +// workspace: one hero, a folded composer, exact observed counts with a short swap transition, reduced motion, and a region failure that +// stays announced without becoming a banner. + +const workValue = (page) => page.getByTestId("goal-vitals").locator(".goal-vital").nth(1).locator(".goal-vital-value").innerText(); + +export const goalShowcaseScenario = { + id: "goal-showcase", + async run({ browser, collectCoverage, url }) { + const coverageEntries = []; + const state = { done: 23, cost: 24.8, deliveryFails: false }; + const session = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width: 1440, height: 900 }, beforeGoto: (api, page) => installShowcaseWorkspace(api, page, state) }); + const { page } = session; + try { + await openShowcaseGoal(page); + const overview = page.locator(".goal-overview"); + assert.equal(await overview.getByRole("alert").count(), 0, "The showcase state has no region failure"); + assert.match(await page.locator(".goal-overview-objective").innerText(), /ledger API/); + assert.match(await page.locator(".goal-overview-progress").innerText(), /2\/4/); + assert.equal(await page.locator(".goal-criteria-meter i").count(), 4, "The meter has one segment per criterion"); + assert.equal(await page.locator(".goal-vital-dot.is-live").count(), 1, "A host-recorded open turn is the one live glyph"); + assert.equal(await workValue(page), "23/28"); + + assert.equal(await overview.getByRole("heading", { level: 2, name: "概览" }).count(), 0, "The tab already names the view; the body does not repeat it"); + assert.equal(await page.getByTestId("overview-composer-pill").count(), 1, "Overview folds the composer to one line"); + assert.equal(await page.getByRole("textbox", { name: /发送消息|Send message/ }).count(), 0); + await page.screenshot({ path: resolve(outputDir, "goal-showcase-overview.png"), animations: "disabled" }); + const suggestions = page.locator(".personal-composer-folded .personal-composer-tools > summary"); + await suggestions.click(); + await page.getByRole("button", { name: "配置定时检查" }).waitFor({ state: "visible" }); + await page.keyboard.press("Escape"); + assert.ok(await suggestions.evaluate((node) => node === document.activeElement), "Escape returns focus to the suggestions trigger"); + await suggestions.click(); + await page.getByRole("button", { name: "配置定时检查" }).waitFor({ state: "visible" }); + await page.screenshot({ path: resolve(outputDir, "goal-showcase-suggestions.png"), animations: "disabled" }); + await suggestions.click(); + + // A refresh may show either observation, never an interpolated task or cost. + state.done = 24; + state.cost = 25.4; + const samples = page.evaluate(() => new Promise((accept) => { + const values = []; + const started = performance.now(); + const read = () => { + const value = document.querySelectorAll('[data-testid="goal-vitals"] .goal-vital')[1]?.querySelector(".goal-vital-value")?.textContent ?? ""; + values.push(value); + if (performance.now() - started < 2_500 && value !== "24/29") requestAnimationFrame(read); + else accept(values); + }; + read(); + })); + await page.getByRole("button", { name: /^(刷新状态|Refresh status)$/ }).click(); + const observed = await samples; + assert.equal(observed.at(-1), "24/29", `The count settles on the new observation: ${observed.slice(-5)}`); + for (const value of observed) { + assert.ok(["23/28", "24/29"].includes(value), `The refresh invented an intermediate count: ${value}`); + } + await page.waitForTimeout(700); + const cost = await page.getByTestId("goal-vitals").locator(".goal-vital").nth(4).locator(".goal-vital-value").innerText(); + assert.equal(cost, "$25.40", "Cost displays the exact observation"); + + // Usage changes window without briefly retaining a value from the old window. + state.usageWindow = "24h"; + await page.getByRole("button", { name: /^(刷新状态|Refresh status)$/ }).click(); + const costCell = page.getByTestId("goal-vitals").locator(".goal-vital").nth(4); + await costCell.locator(".goal-vital-value").filter({hasText: "$6.20"}).waitFor(); + assert.match(await costCell.innerText(), /24/); + state.usageWindow = "unknown"; + await page.getByRole("button", { name: /^(刷新状态|Refresh status)$/ }).click(); + await costCell.locator(".goal-vital-value").filter({hasText: "—"}).waitFor(); + assert.doesNotMatch(await costCell.innerText(), /\$0|\$6|\$25/, "Unknown usage retains no measured cost"); + state.usageWindow = "7d"; + await page.getByRole("button", { name: /^(刷新状态|Refresh status)$/ }).click(); + await costCell.locator(".goal-vital-value").filter({hasText: "$25.40"}).waitFor(); + + await page.getByTestId("overview-composer-pill").click(); + const composer = page.getByRole("textbox", { name: /发送消息|Send message/ }); + await composer.waitFor(); + assert.ok(await composer.evaluate((node) => node === document.activeElement), "Opening the folded composer focuses it"); + await page.locator(".personal-goal-tabs button", { hasText: "任务" }).click(); + await page.locator(".personal-goal-tabs button", { hasText: "概览" }).click(); + await page.getByTestId("overview-composer-pill").waitFor(); + + await page.emulateMedia({ reducedMotion: "reduce" }); + const liveAnimation = await page.locator(".goal-vital-dot.is-live").evaluate((node) => getComputedStyle(node, "::after").animationName); + assert.equal(liveAnimation, "none", "Reduced motion stops the live arc; the ring and core still mark it live"); + await page.emulateMedia({ reducedMotion: null }); + + state.deliveryFails = true; + await page.locator(".delivery-review-toolbar button").first().click(); + const failure = overview.getByRole("alert").filter({ hasText: "刷新失败" }); + await failure.waitFor(); + const box = await failure.boundingBox(); + assert.ok(box && box.height <= 28, `A region failure is one announced line, not a banner (${box?.height}px)`); + await page.screenshot({ path: resolve(outputDir, "goal-showcase-region-error.png"), animations: "disabled" }); + state.deliveryFails = false; + + await page.setViewportSize({ width: 1024, height: 768 }); + await page.screenshot({ path: resolve(outputDir, "goal-showcase-1024.png"), animations: "disabled" }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.screenshot({ path: resolve(outputDir, "goal-showcase-phone.png"), animations: "disabled" }); + const overflow = await page.getByTestId("goal-vitals").evaluate((strip) => strip.scrollWidth - strip.clientWidth); + assert.ok(overflow <= 1, "The phone vitals fit without horizontal scrolling"); + await suggestions.click(); + const phoneMonitor = page.getByRole("button", { name: "配置定时检查" }); + await phoneMonitor.waitFor({ state: "visible" }); + assert.ok(await phoneMonitor.evaluate((node) => { + const rect = node.getBoundingClientRect(); + const hit = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2); + return hit !== null && node.contains(hit); + }), "The phone monitor shortcut is reachable without being clipped or covered"); + await page.keyboard.press("Escape"); + + await page.setViewportSize({ width: 1440, height: 900 }); + await page.evaluate(() => localStorage.setItem("loopx-pw-locale", "en")); + await page.reload({ waitUntil: "networkidle" }); + await page.getByTestId("goal-vitals").waitFor({ state: "visible", timeout: 15_000 }); + if (!(await page.locator(".goal-overview").isVisible())) await openShowcaseGoal(page); + await page.locator(".work-map").waitFor({ timeout: 15_000 }); + await page.screenshot({ path: resolve(outputDir, "goal-showcase-en.png"), animations: "disabled" }); + assert.equal(session.errors.filter((error) => !/503/.test(error)).length, 0, session.errors.join(" | ")); + coverageEntries.push(...await session.close()); + } catch (error) { + await page.screenshot({ path: resolve(outputDir, "goal-showcase-failed.png") }); + await session.close(); + throw error; + } + return { coverageEntries, note: "showcase Goal composition, exact values across usage windows, folded composer, quiet region failure" }; + }, +}; diff --git a/examples/personal-workspace-browser/navigation-sorting.mjs b/examples/personal-workspace-browser/navigation-sorting.mjs index 6bb459d3b8..c0a2b7f6eb 100644 --- a/examples/personal-workspace-browser/navigation-sorting.mjs +++ b/examples/personal-workspace-browser/navigation-sorting.mjs @@ -142,8 +142,9 @@ export const navigationSortingScenario = { const overview = page.locator(".goal-overview"); if (await overview.locator(".goal-overview-attention").count()) throw new Error("A quiet Goal still reserved a full attention card"); if (await overview.getByRole("button", { name: /查看任务|查看成果/ }).count()) throw new Error("Overview duplicated adjacent tab navigation"); - const unknownUsage = await overview.locator(".goal-overview-usage dd").allInnerTexts(); - if (unknownUsage.length !== 3 || unknownUsage.some(value => value !== "未采集")) throw new Error(`Unknown usage became a zero or disappeared: ${unknownUsage}`); + const usageVitals = page.getByTestId("goal-vitals").locator(".goal-vital").filter({ has: page.locator(".goal-vital-label", { hasText: /^(Agent 用时|成本)$/ }) }); + const unknownUsage = await usageVitals.evaluateAll(cells => cells.map(cell => `${cell.querySelector(".goal-vital-value")?.textContent}|${cell.querySelector(".goal-vital-sub")?.textContent}`)); + if (unknownUsage.length !== 2 || unknownUsage.some(value => value !== "—|未采集")) throw new Error(`Unknown usage became a zero or disappeared: ${unknownUsage}`); await overview.getByRole("alert").filter({ hasText: "交付快照读取失败" }).waitFor({ state: "visible" }); await overview.getByText("验收观测不可用,Goal 是否达成仍未知。", { exact: true }).waitFor({ state: "visible" }); await goalNav.getByRole("button", { name: "成果", exact: true }).click(); diff --git a/examples/personal-workspace-browser/showcase-workspace.mjs b/examples/personal-workspace-browser/showcase-workspace.mjs new file mode 100644 index 0000000000..212fe135cf --- /dev/null +++ b/examples/personal-workspace-browser/showcase-workspace.mjs @@ -0,0 +1,153 @@ +// A coherent synthetic workspace for visual review: one software migration +// Goal with two agents, measured usage, an observed live turn, owner +// decisions, a partly verified acceptance contract and a delivery map. UI PRs +// take their before/after screenshots from this state so reviewers judge the +// composition against realistic content rather than fixture placeholders. +// Every name, number and title here is invented. +export const showcaseGoalId = "progress-projection"; +export const showcaseGoalTitle = "Migrate billing exports to the ledger API"; +const goalId = showcaseGoalId; +const title = showcaseGoalTitle; +const minutesAgo = (minutes) => new Date(Date.now() - minutes * 60_000).toISOString(); +const daysAgo = (days) => minutesAgo(days * 24 * 60); + +const workspaceNames = { + "loopx-meta": "Release 2.4 readiness", + "product-release": "Onboarding email experiment", + "research-monitor": "Weekly dependency audit", + "multi-agent-projection": "Search latency investigation", + "legacy-benchmark": "Pricing page copy review", + "archived-notes": "Support macro cleanup", +}; + +const agentTodo = (index, todoId, text, status, extra = {}) => ({ + done: status === "done", index, role: "agent", status, task_class: "advancement_task", text, title: text, todo_id: `showcase-${todoId}`, ...extra, +}); +const openTodos = [ + agentTodo(24, "map-columns", "Map legacy export columns to ledger entry fields", "open", { claimed_by: "ledger-migrator" }), + agentTodo(25, "backfill", "Backfill March–May exports through the ledger API", "open", { claimed_by: "ledger-migrator", dependencies: ["showcase-map-columns"] }), + agentTodo(26, "reconcile", "Reconcile monthly totals against the finance warehouse", "open", { claimed_by: "reconciler" }), + agentTodo(27, "runbook", "Write the cut-over runbook", "open", { claimed_by: "reconciler" }), +]; +const deferredTodo = agentTodo(28, "retire-csv", "Retire the nightly CSV export job", "deferred", { done: true, resume_when: "after:cut-over" }); +const recentDone = [ + agentTodo(21, "shadow", "Shadow-write January exports to the ledger", "done", { completed_at: minutesAgo(95) }), + agentTodo(20, "idempotency", "Add idempotency keys to ledger writes", "done", { completed_at: minutesAgo(260) }), + agentTodo(19, "inventory", "Inventory every export consumer", "done", { completed_at: daysAgo(1) }), +]; + +const contract = (verificationResults) => ({ + enabled: true, revision: 3, digest: `sha256:${"b".repeat(64)}`, + objective: "Every billing export is produced from the ledger API, and its totals reconcile with the finance warehouse.", + scope: { kind: "all_advancement" }, non_goals: [], held_todo_ids: [], status: "partial", + criteria: [ + { id: "regions", description: "All 14 regional exports read from the ledger API" }, + { id: "idempotent", description: "Replaying an export never duplicates a ledger entry" }, + { id: "reconcile", description: "Backfilled months match the archived CSV totals to the cent" }, + { id: "runbook", description: "The cut-over runbook is approved by the on-call owner" }, + ], + tasks: [], + verification: { operation_id: "showcase-verify", contract_revision: 3, contract_digest: `sha256:${"b".repeat(64)}`, todo_id: null, results: verificationResults }, +}); +const acceptance = { + schema_version: "goal_acceptance_observation_projection_v0", goal_id: goalId, read_only: true, acceptance_assessed: false, + coverage: "partial", missing_sources: [], truncated: false, historical_progress: [], acceptance_gaps: [], guards: [], + next_action: null, next_action_source: null, + goal_acceptance_contract: contract([ + { criterion_id: "regions", passed: true, exit_code: 0 }, + { criterion_id: "idempotent", passed: true, exit_code: 0 }, + { criterion_id: "reconcile", passed: false, exit_code: 1 }, + ]), +}; + +const mapNode = (id, kind, nodeTitle, state, depth, extra = {}) => + ({ node_id: `map_${id}`, kind, title: nodeTitle, state, depth, refs: { todo_ids: [`showcase-${id}`] }, ...extra }); +const mapEdge = (from, to, relation, enforcement) => + ({ edge_id: `map_${from}_${to}_${relation}`, from_node_id: `map_${from}`, to_node_id: `map_${to}`, relation, enforcement, reason: "Recorded task relation." }); +function deliverySnapshot() { + return { + ok: true, goal_id: goalId, observed_at: minutesAgo(1), acceptance, + goal_map: { + schema_version: "goal_task_map_v0", mode: "read_only", goal_id: goalId, + limits: { node_limit: 120, emitted_node_count: 9, omitted_node_count: 0, source_truncated: false, missing_endpoint_count: 0, cycle_edge_count: 0, topology_complete: true }, + nodes: [ + mapNode("inventory", "deliverable", "Inventory every export consumer", "done", 0, { owner_agent: "ledger-migrator" }), + mapNode("idempotency", "deliverable", "Add idempotency keys to ledger writes", "done", 1, { owner_agent: "ledger-migrator" }), + mapNode("shadow", "deliverable", "Shadow-write January exports", "done", 2, { owner_agent: "ledger-migrator" }), + mapNode("map-columns", "deliverable", "Map legacy export columns", "open", 3, { owner_agent: "ledger-migrator" }), + mapNode("backfill", "deliverable", "Backfill March–May exports", "blocked", 4, { owner_agent: "ledger-migrator" }), + mapNode("reconcile", "deliverable", "Reconcile monthly totals", "open", 3, { owner_agent: "reconciler" }), + mapNode("cutover-window", "gate", "Approve the cut-over window", "open", 0), + mapNode("retire-csv", "deliverable", "Retire the nightly CSV job", "waiting", 1, { owner_agent: "reconciler" }), + mapNode("error-rate", "monitor", "Watch the ledger API error rate", "open", 0, { owner_agent: "reconciler" }), + ], + edges: [ + mapEdge("idempotency", "inventory", "continues", "lineage_only"), + mapEdge("shadow", "idempotency", "continues", "lineage_only"), + mapEdge("map-columns", "shadow", "continues", "lineage_only"), + mapEdge("backfill", "map-columns", "depends_on", "typed_lifecycle"), + mapEdge("retire-csv", "cutover-window", "depends_on", "typed_lifecycle"), + ], + }, + graph: null, + }; +} + +/** Installs the showcase status, usage and delivery reads. `state` stays live: change it, then refresh. */ +export async function installShowcaseWorkspace(api, page, state) { + await page.route("**/api/chat/delivery-review?*", (route) => state.deliveryFails + ? route.fulfill({ status: 503, contentType: "application/json", json: { ok: false, error: "temporarily unavailable" } }) + : route.fulfill({ contentType: "application/json", json: deliverySnapshot() })); + + api.hostThreadActivity = { [goalId]: { completeness: "complete", threads: [ + { agent_id: "ledger-migrator", host_surface: "codex-app", state: "turn_open", last_event_at: minutesAgo(1) }, + ] } }; + api.transformStatus = (fixture) => { + for (const goal of fixture.run_history.goals) { + if (workspaceNames[goal.id]) goal.display_name = workspaceNames[goal.id]; + } + const goal = fixture.run_history.goals.find((item) => item.id === goalId); + if (goal) Object.assign(goal, { display_name: title, acceptance_observation: acceptance }); + const queueItem = fixture.attention_queue.items.find((item) => item.goal_id === goalId); + if (queueItem) { + const items = [...openTodos, deferredTodo, ...recentDone]; + queueItem.agent_todos = { + advancement_done_count: state.done, done_count: state.done, deferred_count: 1, items, deferred_items: [deferredTodo], + open_count: openTodos.length, source_section: "Agent Todo", total_count: state.done + openTodos.length + 1, + }; + queueItem.project_asset = { + ...queueItem.project_asset, owner: "ledger-migrator", next_action: openTodos[0].text, stop_condition: "All exports reconcile from the ledger API", + agent_todos: { advancement_done_count: state.done, done: state.done, deferred_count: 1, items: openTodos, open: openTodos.length, + recent_completed_advancement_items: recentDone, total: state.done + openTodos.length + 1 }, + }; + queueItem.recommended_action = openTodos[0].text; + queueItem.waiting_on = "user_or_controller"; + queueItem.user_todos = { source_section: "User Todo", open_count: 2, total_count: 2, items: [ + { done: false, status: "open", index: 0, role: "user", task_class: "user_gate", goal_id: goalId, todo_id: "showcase-gate-window", + text: "Approve the Saturday 02:00–04:00 UTC cut-over window", updated_at: minutesAgo(42) }, + { done: false, status: "open", index: 1, role: "user", task_class: "user_action", goal_id: goalId, todo_id: "showcase-action-credentials", + text: "Rotate the finance warehouse read credentials", updated_at: minutesAgo(180) }, + ] }; + } + const lead = fixture.agent_management_projection.agents.find((agent) => agent.goal_ids?.includes(goalId)); + if (lead) { + Object.assign(lead, { agent_id: "ledger-migrator", last_activity_at: minutesAgo(1), next_action: openTodos[0].text, + current_todo: { ...lead.current_todo, claimed_by: "ledger-migrator", title: openTodos[0].text, todo_id: openTodos[0].todo_id } }); + if (!fixture.agent_management_projection.agents.some((agent) => agent.agent_id === "reconciler")) { + fixture.agent_management_projection.agents.push({ agent_id: "reconciler", goal_ids: [goalId], last_activity_at: minutesAgo(14), state: "running", + next_action: openTodos[2].text, current_todo: { claimed_by: "reconciler", goal_id: goalId, role: "agent", status: "open", task_class: "advancement_task", title: openTodos[2].text, todo_id: openTodos[2].todo_id } }); + } + } + fixture.usage_summary.goals.push({ goal_id: goalId, + ...(state.usageWindow === "unknown" ? {} : { cost_usd_24h: 6.2, duration_ms_24h: 3_000_000 }), + ...(!state.usageWindow || state.usageWindow === "7d" ? { cost_usd_7d: state.cost, duration_ms_7d: 13_320_000 } : {}), + }); + }; +} + +export async function openShowcaseGoal(page) { + await page.locator(".personal-goal-row").filter({ has: page.locator("strong", { hasText: title }) }).first().locator(".personal-goal-link").click(); + await page.getByTestId("goal-vitals").waitFor({ state: "visible", timeout: 15_000 }); + await page.getByRole("navigation", { name: /^(Goal 视图|Goal view)$/ }).getByRole("button", { name: /^(概览|Overview)$/ }).click(); + await page.locator(".work-map, .delivery-review [role=alert]").first().waitFor({ timeout: 15_000 }); +} diff --git a/examples/personal-workspace-browser/typed-actions.mjs b/examples/personal-workspace-browser/typed-actions.mjs index 8b62c2afaf..f03c69d411 100644 --- a/examples/personal-workspace-browser/typed-actions.mjs +++ b/examples/personal-workspace-browser/typed-actions.mjs @@ -1913,6 +1913,9 @@ export const typedActionsScenario = { if (monitorShortcut?.normalized_parameters.target !== "检查当前 Goal 的阻塞、进度与新产出") throw new Error(`定时检查快捷方式未回落到本地化默认检查目标:${JSON.stringify(monitorShortcut?.normalized_parameters)}`); if (api.durableWriteCount !== writesBeforeMonitorShortcut) throw new Error("定时检查快捷方式在确认前写入了持久状态"); await page.getByRole("button", { name: "关闭", exact: true }).click(); + if (await page.locator(".personal-composer-tools").getAttribute("open") === null) { + throw new Error("Closing a configuration draft lost the open shortcut menu across composer folding"); + } await page.getByRole("button", {name: "配置定时检查"}).click(); const monitorForm = page.getByRole("dialog", {name: "配置定时检查", exact: true}); const previewsBeforeUnsupportedSchedule = api.actionPreviews.length; diff --git a/loopx/control_plane/presentation/goal_workspace_digest.ts b/loopx/control_plane/presentation/goal_workspace_digest.ts new file mode 100644 index 0000000000..fb3298360a --- /dev/null +++ b/loopx/control_plane/presentation/goal_workspace_digest.ts @@ -0,0 +1,213 @@ +/** One read model for every count the Goal workspace shows, so the vitals strip + * and the work board cannot disagree. Pure: it performs no I/O and reads no + * clock; the caller supplies already-parsed owner facts and `observedAt`. + * + * Liveness stays with the execution observation owner: a claim, a status or a + * registration is never activity, and unknown stays unknown. A missing + * measurement is `null`, never zero. Nothing here is a lifetime figure. */ + +export const GOAL_WORKSPACE_DIGEST_SCHEMA = "goal_workspace_digest_v0"; + +/** Display classes for open and done work. Local to this reducer until a second + * consumer adopts the digest; then they register with the vocabulary owner. */ +export const GOAL_WORK_DISPLAY_CLASSES = [ + "running_observed", "waiting_dependency", "open", "blocked", "deferred", "done", +] as const; +export type GoalWorkDisplayClass = typeof GOAL_WORK_DISPLAY_CLASSES[number]; + +export type DigestTodo = { + todoId: string; + done: boolean; + status?: string | null; + taskClass?: string | null; + dependencies?: readonly string[]; +}; + +export type DigestAttention = { blocking: boolean; updatedAt?: string | null }; + +/** Counts from the attention owner's Goal group, with coverage of its item list. + * A bounded list can carry exact totals but cannot establish the global oldest. */ +export type DigestAttentionSummary = { + blocking: number | null; + optional: number | null; + coverage: DigestCoverage; +}; + +export type DigestUsage = { + costUsd24h?: number; costUsd7d?: number; durationMs24h?: number; durationMs7d?: number; +}; + +/** `observed` is true when a managed Turn or a host-recorded thread event backs + * the running state; a host claim alone is not execution. */ +export type DigestExecution = + | { kind: "running"; observed: boolean; quiet: boolean; lastActivityAt: string | null } + | { kind: "idle" } + | { kind: "unknown" }; + +export type DigestActivity = { actor: string; at: string | null }; + +export type GoalWorkspaceDigestInput = { + observedAt: string; + activation: "active" | "stopped"; + todos: readonly DigestTodo[]; + /** Owner-projected done count; the item list carries only recent completions. */ + doneCount?: number | null; + /** Owner-projected open count of the listed group; more than listed means truncation. */ + openCount?: number | null; + /** Todos whose running step the run timeline reports. */ + runningTodoIds?: readonly string[]; + /** `null` when the attention owner could not be read. */ + attention: readonly DigestAttention[] | null; + attentionSummary?: DigestAttentionSummary; + usage?: DigestUsage | null; + /** `undefined` while the session owner has not been read. */ + execution?: DigestExecution; + /** Observed per-actor execution facts such as host-recorded thread events. + * Todo write times and claim times are hints, not activity. */ + activity?: readonly DigestActivity[]; +}; + +export type DigestCoverage = "complete" | "partial" | "unknown"; + +export type GoalWorkspaceDigest = { + schema: typeof GOAL_WORKSPACE_DIGEST_SCHEMA; + observedAt: string; + lifecycle: { activation: "active" | "stopped"; finishingTurn: boolean }; + work: { + open: number; + /** `null` when the owner projects no done count. */ + done: number | null; + total: number | null; + classes: Record; + coverage: DigestCoverage; + listedOpen: number; + projectedOpen: number | null; + }; + needsYou: + | { availability: "ok"; blocking: number | null; optional: number | null; + coverage: DigestCoverage; listed: number; oldestWaitingSince: string | null } + | { availability: "unavailable" }; + /** Agent time and Cost always share one window. */ + usage: { + window: "7d" | "24h" | null; + agentTimeMs: number | null; + costUsd: number | null; + /** No owner coverage signal exists yet; the cell makes no completeness claim. */ + coverage: "unknown"; + }; + lastActivity: { at: string; actor: string } | null; +}; + +/** The work the board lists: every Agent Todo except standing monitors. */ +export function isGoalWorkTodo(todo: Pick): boolean { + return todo.taskClass !== "continuous_monitor"; +} + +function timeOf(value: string | null | undefined): number | null { + if (!value) return null; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; +} + +function measured(value: number | undefined): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function classifyOpenWork( + todo: DigestTodo, + openIds: ReadonlySet, + runningIds: ReadonlySet, + executionObserved: boolean, +): GoalWorkDisplayClass { + if (todo.status === "deferred") return "deferred"; + if (todo.status === "blocked") return "blocked"; + if (executionObserved && runningIds.has(todo.todoId)) return "running_observed"; + // Only a listed, unfinished prerequisite proves waiting; an unlisted one may be done. + if ((todo.dependencies ?? []).some((id) => id !== todo.todoId && openIds.has(id))) return "waiting_dependency"; + return "open"; +} + +export function compileGoalWorkspaceDigest(input: GoalWorkspaceDigestInput): GoalWorkspaceDigest { + const execution = input.execution; + const executionObserved = execution?.kind === "running" && execution.observed && !execution.quiet; + + const work = input.todos.filter(isGoalWorkTodo); + const openWork = work.filter((todo) => !todo.done); + const openIds = new Set(openWork.map((todo) => todo.todoId)); + const runningIds = new Set(input.runningTodoIds ?? []); + const classes = Object.fromEntries(GOAL_WORK_DISPLAY_CLASSES.map((name) => [name, 0])) as Record; + for (const todo of openWork) classes[classifyOpenWork(todo, openIds, runningIds, executionObserved)] += 1; + const listedDone = work.length - openWork.length; + const done = typeof input.doneCount === "number" && input.doneCount >= 0 + ? Math.max(input.doneCount, listedDone) : null; + classes.done = done ?? listedDone; + const listedOpen = input.todos.filter((todo) => !todo.done).length; + const projectedOpen = typeof input.openCount === "number" && input.openCount >= 0 ? input.openCount : null; + const truncated = projectedOpen !== null && projectedOpen > listedOpen; + + let needsYou: GoalWorkspaceDigest["needsYou"]; + if (input.attention === null) { + needsYou = { availability: "unavailable" }; + } else { + const blocking = input.attention.filter((item) => item.blocking); + const count = (value: number | null | undefined, minimum: number): number | null => + typeof value === "number" && Number.isSafeInteger(value) && value >= minimum ? value : null; + const blockingCount = count(input.attentionSummary?.blocking, blocking.length); + const optionalCount = count(input.attentionSummary?.optional, input.attention.length - blocking.length); + const coverage: DigestCoverage = blockingCount === null || optionalCount === null ? "unknown" + : blockingCount + optionalCount > input.attention.length ? "partial" + : input.attentionSummary?.coverage ?? "unknown"; + const oldest = blocking + .map((item) => ({ raw: item.updatedAt ?? null, at: timeOf(item.updatedAt) })) + .filter((item): item is { raw: string; at: number } => item.at !== null) + .sort((left, right) => left.at - right.at)[0]; + needsYou = { + availability: "ok", + blocking: blockingCount, + optional: optionalCount, + coverage, + listed: input.attention.length, + oldestWaitingSince: coverage === "complete" ? oldest?.raw ?? null : null, + }; + } + + const usage = input.usage ?? null; + const wide = usage && (measured(usage.durationMs7d) !== null || measured(usage.costUsd7d) !== null); + const narrow = usage && (measured(usage.durationMs24h) !== null || measured(usage.costUsd24h) !== null); + const window = wide ? "7d" as const : narrow ? "24h" as const : null; + + const candidates: { actor: string; at: string; time: number }[] = []; + if (execution?.kind === "running" && execution.observed) { + const time = timeOf(execution.lastActivityAt); + if (time !== null) candidates.push({ actor: "", at: execution.lastActivityAt!, time }); + } + for (const fact of input.activity ?? []) { + const time = timeOf(fact.at); + if (time !== null) candidates.push({ actor: fact.actor, at: fact.at!, time }); + } + // The newest fact wins; at equal times a named actor is more useful than the anonymous Turn. + const latest = candidates.sort((left, right) => right.time - left.time || Number(Boolean(right.actor)) - Number(Boolean(left.actor)))[0]; + + return { + schema: GOAL_WORKSPACE_DIGEST_SCHEMA, + observedAt: input.observedAt, + lifecycle: { activation: input.activation, finishingTurn: input.activation === "stopped" && executionObserved }, + work: { + open: openWork.length, + done, + total: done === null ? null : done + openWork.length, + classes, + coverage: truncated ? "partial" : "complete", + listedOpen, + projectedOpen, + }, + needsYou, + usage: { + window, + agentTimeMs: window === "7d" ? measured(usage?.durationMs7d) : window === "24h" ? measured(usage?.durationMs24h) : null, + costUsd: window === "7d" ? measured(usage?.costUsd7d) : window === "24h" ? measured(usage?.costUsd24h) : null, + coverage: "unknown", + }, + lastActivity: latest ? { at: latest.at, actor: latest.actor } : null, + }; +} diff --git a/tests/control_plane_ts/goal_workspace_digest.test.ts b/tests/control_plane_ts/goal_workspace_digest.test.ts new file mode 100644 index 0000000000..7b00abf97a --- /dev/null +++ b/tests/control_plane_ts/goal_workspace_digest.test.ts @@ -0,0 +1,173 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + GOAL_WORK_DISPLAY_CLASSES, + compileGoalWorkspaceDigest, + isGoalWorkTodo, + type GoalWorkspaceDigestInput, +} from "../../loopx/control_plane/presentation/goal_workspace_digest.ts"; + +const observedAt = "2026-10-11T10:00:00.000Z"; +const base: GoalWorkspaceDigestInput = { observedAt, activation: "active", todos: [], attention: [], + attentionSummary: { blocking: 0, optional: 0, coverage: "complete" } }; +const running = (observed: boolean, quiet = false, lastActivityAt: string | null = "2026-10-11T09:58:00.000Z") => + ({ kind: "running" as const, observed, quiet, lastActivityAt }); + +test("absent usage is unknown, never zero, and a measured zero stays zero", () => { + for (const usage of [undefined, null, {}]) { + const digest = compileGoalWorkspaceDigest({ ...base, usage }); + assert.deepEqual(digest.usage, { window: null, agentTimeMs: null, costUsd: null, coverage: "unknown" }); + } + const zero = compileGoalWorkspaceDigest({ ...base, usage: { costUsd24h: 0, durationMs24h: 0 } }); + assert.deepEqual([zero.usage.window, zero.usage.costUsd, zero.usage.agentTimeMs], ["24h", 0, 0]); + const nonFinite = compileGoalWorkspaceDigest({ ...base, usage: { costUsd7d: Number.NaN } }); + assert.equal(nonFinite.usage.window, null); +}); + +test("Agent time and Cost share the widest measured window without borrowing a narrower value", () => { + const digest = compileGoalWorkspaceDigest({ ...base, usage: { costUsd7d: 18.4, durationMs24h: 60_000 } }); + assert.equal(digest.usage.window, "7d"); + assert.equal(digest.usage.costUsd, 18.4); + assert.equal(digest.usage.agentTimeMs, null, "a 24h duration must not appear beside a 7-day cost"); + const costless = compileGoalWorkspaceDigest({ ...base, usage: { durationMs7d: 3_600_000, costUsd24h: 3.1 } }); + assert.deepEqual([costless.usage.window, costless.usage.agentTimeMs, costless.usage.costUsd], ["7d", 3_600_000, null], + "a 24h cost must not appear beside a 7-day duration"); + const narrow = compileGoalWorkspaceDigest({ ...base, usage: { costUsd24h: 3.1, durationMs24h: 120_000 } }); + assert.deepEqual([narrow.usage.window, narrow.usage.costUsd, narrow.usage.agentTimeMs], ["24h", 3.1, 120_000]); +}); + +test("work counts exclude monitors, take done from the owner and agree with the ribbon", () => { + const todos = [ + { todoId: "a", done: false, status: "open", taskClass: "advancement_task" }, + { todoId: "b", done: false, status: "blocked", taskClass: "advancement_task" }, + { todoId: "c", done: false, status: "deferred", taskClass: "advancement_task" }, + { todoId: "m", done: false, status: "open", taskClass: "continuous_monitor" }, + { todoId: "d", done: true, status: "done", taskClass: "advancement_task" }, + ]; + const digest = compileGoalWorkspaceDigest({ ...base, todos, doneCount: 54 }); + assert.equal(digest.work.open, 3); + assert.equal(digest.work.done, 54); + assert.equal(digest.work.total, 57); + assert.deepEqual(digest.work.classes, { running_observed: 0, waiting_dependency: 0, open: 1, blocked: 1, deferred: 1, done: 54 }); + const ribbon = GOAL_WORK_DISPLAY_CLASSES.reduce((sum, name) => sum + digest.work.classes[name], 0); + assert.equal(ribbon, digest.work.total, "every counted Todo lands in exactly one segment"); + assert.equal(todos.filter(isGoalWorkTodo).filter((todo) => !todo.done).length, digest.work.open, + "the board predicate and the strip count the same open work"); +}); + +test("a done count below the listed completions cannot shrink, and no owner count stays unknown", () => { + const todos = [{ todoId: "x", done: true }, { todoId: "y", done: true }, { todoId: "z", done: false }]; + assert.equal(compileGoalWorkspaceDigest({ ...base, todos, doneCount: 1 }).work.done, 2); + const unknown = compileGoalWorkspaceDigest({ ...base, todos }); + assert.equal(unknown.work.done, null); + assert.equal(unknown.work.total, null); + assert.equal(unknown.work.open, 1); +}); + +test("a projected open count above the listed items marks the work count partial", () => { + const todos = [{ todoId: "a", done: false }, { todoId: "m", done: false, taskClass: "continuous_monitor" }]; + assert.equal(compileGoalWorkspaceDigest({ ...base, todos, openCount: 2 }).work.coverage, "complete"); + const truncated = compileGoalWorkspaceDigest({ ...base, todos, openCount: 9 }); + assert.equal(truncated.work.coverage, "partial"); + assert.deepEqual([truncated.work.listedOpen, truncated.work.projectedOpen], [2, 9]); +}); + +test("running treatment needs an observed, fresh execution and the timeline's running step", () => { + const todos = [{ todoId: "a", done: false, status: "open" }, { todoId: "b", done: false, status: "open" }]; + const live = compileGoalWorkspaceDigest({ ...base, todos, runningTodoIds: ["a"], execution: running(true) }); + assert.equal(live.work.classes.running_observed, 1); + assert.equal(live.work.classes.open, 1); + for (const execution of [running(true, true), running(false), { kind: "idle" as const }, { kind: "unknown" as const }, undefined]) { + const digest = compileGoalWorkspaceDigest({ ...base, todos, runningTodoIds: ["a"], execution }); + assert.equal(digest.work.classes.running_observed, 0, JSON.stringify(execution)); + } +}); + +test("only a listed unfinished prerequisite makes work wait", () => { + const todos = [ + { todoId: "first", done: false, status: "open" }, + { todoId: "second", done: false, status: "open", dependencies: ["first"] }, + { todoId: "third", done: false, status: "open", dependencies: ["finished", "unlisted"] }, + { todoId: "self", done: false, status: "open", dependencies: ["self"] }, + { todoId: "finished", done: true }, + ]; + const digest = compileGoalWorkspaceDigest({ ...base, todos }); + assert.equal(digest.work.classes.waiting_dependency, 1); + assert.equal(digest.work.classes.open, 3); +}); + +test("needs-you counts blocking decisions, keeps optional ones apart and reports unreadable as unavailable", () => { + const digest = compileGoalWorkspaceDigest({ ...base, attention: [ + { blocking: true, updatedAt: "2026-10-11T08:00:00.000Z" }, + { blocking: true, updatedAt: "2026-10-11T06:30:00.000Z" }, + { blocking: true, updatedAt: "not a time" }, + { blocking: false, updatedAt: "2026-10-10T00:00:00.000Z" }, + ], attentionSummary: { blocking: 3, optional: 1, coverage: "complete" } }); + assert.deepEqual(digest.needsYou, { availability: "ok", blocking: 3, optional: 1, coverage: "complete", listed: 4, oldestWaitingSince: "2026-10-11T06:30:00.000Z" }); + assert.deepEqual(compileGoalWorkspaceDigest({ ...base, attention: null }).needsYou, { availability: "unavailable" }); + assert.deepEqual(compileGoalWorkspaceDigest(base).needsYou, { availability: "ok", blocking: 0, optional: 0, coverage: "complete", listed: 0, oldestWaitingSince: null }); +}); + +test("21 owner decisions stay 21 when the overview lists only three; their oldest time is unknown", () => { + const attention = Array.from({ length: 3 }, () => ({ blocking: true, updatedAt: "2026-10-11T08:00:00.000Z" })); + const digest = compileGoalWorkspaceDigest({ ...base, attention, + attentionSummary: { blocking: 21, optional: 0, coverage: "partial" } }); + assert.deepEqual(digest.needsYou, { availability: "ok", blocking: 21, optional: 0, + coverage: "partial", listed: 3, oldestWaitingSince: null }); + const optional = compileGoalWorkspaceDigest({ ...base, attention: attention.map(item => ({ ...item, blocking: false })), + attentionSummary: { blocking: 0, optional: 21, coverage: "partial" } }); + assert.deepEqual(optional.needsYou, { availability: "ok", blocking: 0, optional: 21, + coverage: "partial", listed: 3, oldestWaitingSince: null }); +}); + +test("empty or missing overview coverage cannot invent zero, a complete count or a global oldest time", () => { + const attention = [{ blocking: true, updatedAt: "2026-10-10T00:00:00Z" }]; + const missing = compileGoalWorkspaceDigest({ ...base, attention, attentionSummary: undefined }); + assert.deepEqual(missing.needsYou, { availability: "ok", blocking: null, optional: null, + coverage: "unknown", listed: 1, oldestWaitingSince: null }); + const empty = compileGoalWorkspaceDigest({ ...base, attention: [], + attentionSummary: { blocking: 21, optional: 0, coverage: "partial" } }); + assert.deepEqual(empty.needsYou, { availability: "ok", blocking: 21, optional: 0, + coverage: "partial", listed: 0, oldestWaitingSince: null }); + for (const count of [0, -1, NaN, Infinity, 0.5]) { + const bad = compileGoalWorkspaceDigest({ ...base, attention, + attentionSummary: { blocking: count, optional: 0, coverage: "complete" } }); + assert.equal(bad.needsYou.availability, "ok"); + if (bad.needsYou.availability === "ok") { + assert.equal(bad.needsYou.blocking, null); + assert.equal(bad.needsYou.coverage, "unknown"); + assert.equal(bad.needsYou.oldestWaitingSince, null); + } + } +}); + +test("a pause reads back a finishing Turn only while one is observed", () => { + const stopped = { ...base, activation: "stopped" as const }; + assert.equal(compileGoalWorkspaceDigest({ ...stopped, execution: running(true) }).lifecycle.finishingTurn, true); + assert.equal(compileGoalWorkspaceDigest({ ...stopped, execution: running(true, true) }).lifecycle.finishingTurn, false); + assert.equal(compileGoalWorkspaceDigest({ ...stopped, execution: running(false) }).lifecycle.finishingTurn, false); + assert.equal(compileGoalWorkspaceDigest({ ...stopped, execution: { kind: "idle" } }).lifecycle.finishingTurn, false); + assert.equal(compileGoalWorkspaceDigest({ ...base, execution: running(true) }).lifecycle.finishingTurn, false); +}); + +test("last activity is the newest observed fact and never a claim", () => { + const digest = compileGoalWorkspaceDigest({ ...base, execution: running(true, false, "2026-10-11T09:40:00.000Z"), activity: [ + { actor: "review-agent", at: "2026-10-11T09:20:00.000Z" }, + { actor: "data-agent", at: "2026-10-11T09:51:00.000Z" }, + { actor: "broken", at: "yesterday" }, + { actor: "never", at: null }, + ] }); + assert.deepEqual(digest.lastActivity, { at: "2026-10-11T09:51:00.000Z", actor: "data-agent" }); + const claimOnly = compileGoalWorkspaceDigest({ ...base, execution: running(false, false, "2026-10-11T09:59:00.000Z") }); + assert.equal(claimOnly.lastActivity, null); + const turn = compileGoalWorkspaceDigest({ ...base, execution: running(true, true, "2026-10-11T09:00:00.000Z") }); + assert.deepEqual(turn.lastActivity, { at: "2026-10-11T09:00:00.000Z", actor: "" }, "a quiet Turn still dates the last observed fact"); +}); + +test("the reducer is deterministic and leaves its input untouched", () => { + const input = Object.freeze({ ...base, todos: Object.freeze([Object.freeze({ todoId: "a", done: false })]), + attention: Object.freeze([Object.freeze({ blocking: true })]), activity: Object.freeze([]) }); + assert.deepEqual(compileGoalWorkspaceDigest(input), compileGoalWorkspaceDigest(input)); + assert.equal(compileGoalWorkspaceDigest(input).schema, "goal_workspace_digest_v0"); +}); diff --git a/tsconfig.control-plane.json b/tsconfig.control-plane.json index 6e84245ce3..21c870a899 100644 --- a/tsconfig.control-plane.json +++ b/tsconfig.control-plane.json @@ -31,6 +31,7 @@ "loopx/control_plane/capability_hooks.ts", "loopx/control_plane/post_writeback_hook_transaction.ts", "loopx/control_plane/presentation/action_review_plan.ts", + "loopx/control_plane/presentation/goal_workspace_digest.ts", "loopx/control_plane/coordination/authority_store.ts", "loopx/control_plane/coordination/authority_store_codec.ts", "loopx/control_plane/coordination/authority_state_log.ts", @@ -99,6 +100,7 @@ "tests/control_plane_ts/effect_runtime_errors.test.ts", "tests/control_plane_ts/post_writeback_hook_transaction.test.ts", "tests/control_plane_ts/action_review_plan.test.ts", + "tests/control_plane_ts/goal_workspace_digest.test.ts", "tests/control_plane_ts/interaction_contract.test.ts", "tests/control_plane_ts/runtime_decode.test.ts", "tests/control_plane_ts/authority_store.test.ts",