diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index 6068dc570..4c3c310fd 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -27,12 +27,13 @@ "smoke:frontstage-route": "rm -rf /tmp/loopx-frontstage-route-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck --strict --outDir /tmp/loopx-frontstage-route-smoke smoke/frontstage-route-smoke.ts && node /tmp/loopx-frontstage-route-smoke/frontstage-route-smoke.js", "smoke:frontstage-share-bundle": "node ../../../examples/frontstage-share-bundle-smoke.mjs", "smoke:goal-order": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --skipLibCheck --strict --outDir node_modules/.cache/loopx-goal-order src/features/personal-workspace/goal-order.ts && node src/features/personal-workspace/goal-order.test.mjs", + "smoke:proposal-recency": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --skipLibCheck --strict --outDir node_modules/.cache/loopx-proposal-recency src/features/personal-workspace/proposal-recency.ts && node src/features/personal-workspace/proposal-recency.test.mjs", "smoke:home-browser": "node ../../../examples/dashboard-home-browser-smoke.mjs", "smoke:home-route": "rm -rf /tmp/loopx-home-route-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-home-route-smoke smoke/home-route-smoke.ts && node /tmp/loopx-home-route-smoke/home-route-smoke.js", "smoke:delegation-preflight": "rm -rf node_modules/.cache/loopx-delegation-preflight-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --jsx react-jsx --skipLibCheck --strict --rootDir . --outDir node_modules/.cache/loopx-delegation-preflight-smoke smoke/delegation-preflight-smoke.tsx src/data/delegation-preflight.ts src/features/personal-workspace/delegation-preflight-status.tsx && node node_modules/.cache/loopx-delegation-preflight-smoke/smoke/delegation-preflight-smoke.js", - "smoke:personal-workspace": "npm run smoke:goal-order && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser-smoke.mjs", + "smoke:personal-workspace": "npm run smoke:goal-order && npm run smoke:proposal-recency && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:workspace-locale": "LOOPX_PERSONAL_WORKSPACE_SCENARIO=workspace-locale node ../../../examples/personal-workspace-browser-smoke.mjs", - "smoke:personal-workspace-packaged": "npm run smoke:goal-order && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs", + "smoke:personal-workspace-packaged": "npm run smoke:goal-order && npm run smoke:proposal-recency && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:todo-resume-condition": "rm -rf /tmp/loopx-todo-resume-condition-smoke && tsc --ignoreConfig --target ES2022 --module commonjs --moduleResolution node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-todo-resume-condition-smoke smoke/todo-resume-condition-smoke.ts src/features/personal-workspace/todo-resume-condition.ts && node /tmp/loopx-todo-resume-condition-smoke/smoke/todo-resume-condition-smoke.js", "smoke:presentation-surface-schema": "rm -rf /tmp/loopx-presentation-surface-schema-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --resolveJsonModule --esModuleInterop --outDir /tmp/loopx-presentation-surface-schema-smoke smoke/presentation-surface-schema-smoke.ts src/data/status.ts src/data/decision-research.ts src/data/goal-channel-frontstage.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-presentation-surface-schema-smoke/apps/presentation/dashboard/smoke/presentation-surface-schema-smoke.js", "smoke:projection-localization": "rm -rf /tmp/loopx-projection-localization-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck --strict --outDir /tmp/loopx-projection-localization-smoke smoke/projection-localization-smoke.ts src/features/personal-workspace/projection-localization.ts && node /tmp/loopx-projection-localization-smoke/smoke/projection-localization-smoke.js", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx index 5325c10eb..82f47d689 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx @@ -12,6 +12,7 @@ import { ScheduleRow } from "./cards/schedule-row"; import { useWorkspaceI18n } from "./i18n"; import { ReturnDeliveryStatus } from "./return-delivery-status"; import {ManagerTeamResult} from "./manager-team-result"; +import { compareProposalRecency } from "./proposal-recency"; import type { WorkspaceDrawerSelection, WorkspaceGoal, WorkspaceMessage, WorkspaceTimelineItem } from "./personal-workspace-model"; function answerLink(sessionId: string, messageId: string) { @@ -144,17 +145,32 @@ export function ChannelTimeline({ // visible; no prose-based inference that a waiting run is safe to ignore. const routineRuns = items.filter((item): item is Extract => item.kind === "run" && ["queued", "running", "completed"].includes(item.run.status)); - const routineIds = new Set(routineRuns.map(item => item.id)); - const primaryItems = items.filter(item => item.kind !== "proposal" && !routineIds.has(item.id)); + const scheduleItems = items.filter((item): item is Extract => item.kind === "schedule"); + const backgroundIds = new Set([...routineRuns, ...scheduleItems].map(item => item.id)); + const primaryItems = items.filter(item => item.kind !== "proposal" && !backgroundIds.has(item.id)); + const pausedScheduleCount = scheduleItems.filter(item => item.schedule.status === "paused").length; + const enabledScheduleCount = scheduleItems.length - pausedScheduleCount; const workingCount = routineRuns.filter(item => item.run.status === "running" && Boolean(item.run.sessionId) && Boolean(item.run.canInterrupt)).length; const queuedCount = routineRuns.filter(item => item.run.status === "queued").length; const completedCount = routineRuns.filter(item => item.run.status === "completed").length; const progressCount = routineRuns.length - workingCount - queuedCount - completedCount; - const activitySummary = locale === "zh-CN" - ? [workingCount && `${workingCount} 个执行中`, queuedCount && `${queuedCount} 个排队中`, completedCount && `${completedCount} 次执行已结束`, progressCount && `${progressCount} 项进展更新`].filter(Boolean).join(" · ") - : [workingCount && `${workingCount} running`, queuedCount && `${queuedCount} queued`, completedCount && `${completedCount} runs finished`, progressCount && `${progressCount} progress updates`].filter(Boolean).join(" · "); + const activitySummary = ([ + enabledScheduleCount ? t("timeline.backgroundSchedules", { count: enabledScheduleCount }) : null, + pausedScheduleCount ? t("timeline.backgroundPaused", { count: pausedScheduleCount }) : null, + ] as Array).concat(locale === "zh-CN" + ? [workingCount && `${workingCount} 个执行中`, queuedCount && `${queuedCount} 个排队中`, completedCount && `${completedCount} 次执行已结束`, progressCount && `${progressCount} 项进展更新`] + : [workingCount && `${workingCount} running`, queuedCount && `${queuedCount} queued`, completedCount && `${completedCount} runs finished`, progressCount && `${progressCount} progress updates`]).filter(Boolean).join(" · "); const activeProposalItems = items.filter((item): item is Extract => item.kind === "proposal" && item.proposal.status !== "gated"); + // Only drafts awaiting the owner fold behind the newest one; applying, applied and failed results stay visible. + // "Newest" is read from the stored proposal, not from the position in this + // list: a restore arrives newest first and a draft created in this session is + // appended last, so a positional rule keeps the wrong draft on the first screen. + const readyProposalItems = activeProposalItems.filter(item => item.proposal.status === "ready"); + const readyByRecency = [...readyProposalItems].sort((a, b) => compareProposalRecency(a.proposal, b.proposal)); + const foldedProposalItems = readyByRecency.slice(1); + const foldedProposalIds = new Set(foldedProposalItems.map(item => item.id)); + const visibleProposalItems = activeProposalItems.filter(item => !foldedProposalIds.has(item.id)); function renderItem(item: WorkspaceTimelineItem) { if (item.kind === "attention") { @@ -172,10 +188,10 @@ export function ChannelTimeline({ if (item.kind === "proposal") { const appliedTeamPlan = item.proposal.actionKind === "team.plan" && item.proposal.status === "applied"; return ( - {showManagerTeamResults && onOpenGoalEvidence && appliedTeamPlan && item.proposal.goalId && item.proposal.teamPlanTodoIds?.length @@ -207,9 +223,9 @@ export function ChannelTimeline({ <>

{liveAnnouncement}

- {routineRuns.length ?
- -
{routineRuns.map(renderItem)}
+ {backgroundIds.size ?
+ +
{scheduleItems.map(renderItem)}{routineRuns.map(renderItem)}
: null} {primaryItems.map(renderItem)} {gatedItems.length ? ( @@ -218,7 +234,13 @@ export function ChannelTimeline({
{gatedItems.map(renderItem)}
) : null} - {activeProposalItems.map(renderItem)} + {foldedProposalIds.size ? ( +
+ +
{foldedProposalItems.map(renderItem)}
+
+ ) : null} + {visibleProposalItems.map(renderItem)}
); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 260967e0a..264c8bad2 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -1058,6 +1058,32 @@ const en = { "activity.claimedAt": "claimed {time}", "activity.viaHost": "{host} host", "activity.quiet": "no new activity for {minutes} min", + "proposal.kind.goal.create": "New Goal", + "proposal.kind.goal.update": "Goal update", + "proposal.kind.goal.lifecycle": "Goal lifecycle", + "proposal.kind.todo.create": "New Task", + "proposal.kind.todo.update": "Task update", + "proposal.kind.agent.bind": "Agent binding", + "proposal.kind.heartbeat.bind": "Heartbeat", + "proposal.kind.monitor.create": "New scheduled check", + "proposal.kind.monitor.update": "Scheduled check change", + "proposal.kind.gate.resolve": "Confirmation", + "proposal.kind.run.correct": "Run correction", + "proposal.kind.operation.execute": "Operation", + "proposal.kind.team.plan": "Team assignment", + "proposal.status.draft": "Draft", + "proposal.status.ready": "Awaiting you", + "proposal.status.applying": "Applying", + "proposal.status.applied": "Applied", + "proposal.status.gated": "Needs authorization", + "proposal.status.stale": "Out of date", + "proposal.status.error": "Failed", + "proposal.status.rejected": "Rejected", + "proposal.status.deferred": "Deferred", + "timeline.background": "Background work", + "timeline.backgroundSchedules": "{count} scheduled enabled", + "timeline.backgroundPaused": "{count} paused", + "timeline.olderDrafts": "{count} more drafts awaiting you", "brief.title": "Today's brief", "brief.needs": "Needs you", "brief.needsEmpty": "Nothing is waiting on you", @@ -2178,6 +2204,32 @@ const zhCN: Record = { "activity.claimedAt": "领取于{time}", "activity.viaHost": "{host} 宿主", "activity.quiet": "已 {minutes} 分钟无新动静", + "proposal.kind.goal.create": "新 Goal", + "proposal.kind.goal.update": "更新 Goal", + "proposal.kind.goal.lifecycle": "Goal 启停", + "proposal.kind.todo.create": "新任务", + "proposal.kind.todo.update": "更新任务", + "proposal.kind.agent.bind": "绑定 Agent", + "proposal.kind.heartbeat.bind": "设置 Heartbeat", + "proposal.kind.monitor.create": "新定时检查", + "proposal.kind.monitor.update": "调整定时检查", + "proposal.kind.gate.resolve": "处理确认项", + "proposal.kind.run.correct": "纠偏执行", + "proposal.kind.operation.execute": "执行操作", + "proposal.kind.team.plan": "团队分配", + "proposal.status.draft": "草稿", + "proposal.status.ready": "待你确认", + "proposal.status.applying": "正在应用", + "proposal.status.applied": "已应用", + "proposal.status.gated": "等待授权", + "proposal.status.stale": "已过期", + "proposal.status.error": "出错", + "proposal.status.rejected": "已拒绝", + "proposal.status.deferred": "已延后", + "timeline.background": "后台工作", + "timeline.backgroundSchedules": "{count} 个定时任务已启用", + "timeline.backgroundPaused": "{count} 个已暂停", + "timeline.olderDrafts": "另有 {count} 个待确认提议", "brief.title": "今日简报", "brief.needs": "等你处理", "brief.needsEmpty": "没有等你处理的事", 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 286f5b2e9..13b1864fd 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 @@ -247,6 +247,12 @@ export type WorkspaceActionPreview = { nextAction?: string; summary: string; }; + // The stored typed action's own times. The workspace restores the list in + // `ChatActionStore.list` order, which is (`updated_at`, `proposal_id`) + // newest first, while a draft created in this session is appended last; a + // reader that needs the newest draft compares these instead of the position. + // See proposal-recency.ts. + createdAt?: string; previewId: string; primaryLabel?: string; errorMessage?: string; @@ -260,6 +266,7 @@ export type WorkspaceActionPreview = { teamPlanTodoIds?: string[]; title: string; sourceRequest?: WorkspaceActionPreviewRequest; + updatedAt?: string; workspaceCandidates?: Array<{ label: string; workspaceRef: string }>; }; 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 e6323ab4d..6291183bc 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 @@ -65,6 +65,7 @@ import { GoalActivityChip, GoalIdentityMark } from "./goal-activity-view"; import { ManagerBrief } from "./manager-brief"; import { WorkspaceSettingsPage } from "./workspace-settings-page"; import { readWorkspaceTheme, writeWorkspaceTheme, type WorkspaceTheme } from "./workspace-theme"; +import { compareProposalRecency } from "./proposal-recency"; import { WorkspaceShell } from "./workspace-shell"; import type { StatusSourceControl } from "./status-source-switcher"; import "./personal-workspace.css"; @@ -74,7 +75,11 @@ function dedupeProposals(proposals: WorkspaceActionPreview[]): WorkspaceActionPr proposals.forEach((proposal) => { const subject = proposal.fields.find((field) => field.key === "todo_id")?.value ?? ""; const key = [proposal.actionKind, proposal.goalId ?? "", subject, proposal.title].join(":"); - latest.set(key, proposal); + // Two records can describe the same draft; keep the newest by its stored + // time rather than whichever one this list happened to end with, since a + // restored list and a session-created draft arrive in opposite orders. + const current = latest.get(key); + if (!current || compareProposalRecency(proposal, current) < 0) latest.set(key, proposal); }); return [...latest.values()]; } @@ -686,6 +691,8 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate) ? teamPlanReceiptGapLanes(proposal.receipt, proposal.normalized_parameters) : undefined, title: localizedSummary, + updatedAt: proposal.updated_at, + createdAt: proposal.created_at, }; } 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 83330fab5..6aac1fed5 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -1843,11 +1843,11 @@ /* Whole-channel attention hierarchy: conversation first, details on demand. */ .personal-channel:has(> .goal-loopx-mode) { grid-template-rows: auto auto minmax(0, 1fr) auto; } .personal-run-row { grid-template-columns: 38px minmax(0, 1fr) auto 16px; } -.personal-activity-summary { border-bottom: 1px solid var(--pw-line); } -.personal-activity-summary > summary { display: flex; align-items: center; gap: 10px; cursor: pointer; min-height: 44px; color: var(--pw-muted); font-size: 12px; flex-wrap: wrap; } -.personal-activity-summary > summary::after { content: "+"; margin-left: auto; } -.personal-activity-summary[open] > summary::after { content: "−"; } -.personal-activity-summary > div { display: grid; gap: 8px; padding: 8px 0 16px; } +.personal-activity-summary, .personal-proposal-backlog { border-bottom: 1px solid var(--pw-line); } +.personal-activity-summary > summary, .personal-proposal-backlog > summary { display: flex; align-items: center; gap: 10px; cursor: pointer; min-height: 44px; color: var(--pw-muted); font-size: 12px; flex-wrap: wrap; } +.personal-activity-summary > summary::after, .personal-proposal-backlog > summary::after { content: "+"; margin-left: auto; } +.personal-activity-summary[open] > summary::after, .personal-proposal-backlog[open] > summary::after { content: "−"; } +.personal-activity-summary > div, .personal-proposal-backlog > div { display: grid; gap: 8px; padding: 8px 0 16px; } .personal-composer-tools { margin-bottom: 6px; } .personal-composer-tools > summary, .personal-runtime-details > summary { cursor: pointer; color: var(--pw-muted); font-size: 12px; min-height: 44px; padding-block: 12px; } .personal-composer-tools .personal-quick-prompts { flex-wrap: wrap; overflow: visible; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/proposal-recency.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/proposal-recency.test.mjs new file mode 100644 index 000000000..db4a2e2db --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/proposal-recency.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { + compareProposalRecency, + olderProposals, + proposalRecencyKey, +} from "../../../node_modules/.cache/loopx-proposal-recency/proposal-recency.js"; + +// The store's contract: `ChatActionStore.list` sorts by +// (`updated_at`, `proposal_id`) with `reverse=True`, so a restored list arrives +// newest first and a draft created in this session is appended last. +const stored = (previewId, updatedAt, createdAt = updatedAt) => ({ createdAt, previewId, updatedAt }); +const newest = stored("draft-b", "2026-09-14T02:00:00Z"); +const older = stored("draft-a", "2026-09-14T01:00:00Z"); + +// The two real arrival orders must agree about which draft is newest. +assert.deepEqual([older, newest].sort(compareProposalRecency), [newest, older]); +assert.deepEqual([newest, older].sort(compareProposalRecency), [newest, older]); +assert.deepEqual(olderProposals([older, newest]), [older]); +assert.deepEqual(olderProposals([newest, older]), [older]); +assert.deepEqual(olderProposals([newest]), []); +assert.deepEqual(olderProposals([]), []); + +// Ties fall back to the id, in the same direction the store sorts it, so the +// answer is stable instead of depending on which record was listed first. +const tieLow = stored("draft-a", "2026-09-14T02:00:00Z"); +assert.equal(compareProposalRecency(tieLow, newest), 1); +assert.equal(compareProposalRecency(newest, tieLow), -1); +assert.equal(compareProposalRecency(newest, stored("draft-b", "2026-09-14T02:00:00Z")), 0); + +// A draft that never reached the store still orders deterministically, and a +// stored draft stays ahead of it because a timestamp outranks an absent one. +const hostOnly = { previewId: "draft-z" }; +assert.deepEqual(proposalRecencyKey(hostOnly), ["", "draft-z"]); +assert.equal(compareProposalRecency(newest, hostOnly), -1); +assert.equal(compareProposalRecency(hostOnly, newest), 1); + +// A host preview that carries only `created_at` still compares by that time. +const createdAtOnly = { createdAt: "2026-09-14T03:00:00Z", previewId: "draft-c" }; +assert.deepEqual(proposalRecencyKey(createdAtOnly), ["2026-09-14T03:00:00Z", "draft-c"]); +assert.equal(compareProposalRecency(createdAtOnly, newest), -1); + +// Recency follows the record, not the position: the same records in the +// contract-violating order still name the same newest draft. +const reversed = [newest, older]; +assert.equal(reversed.slice().sort(compareProposalRecency)[0].previewId, "draft-b"); +assert.equal([older, newest].slice().sort(compareProposalRecency)[0].previewId, "draft-b"); + +console.log("Proposal recency invariants passed"); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/proposal-recency.ts b/apps/presentation/dashboard/src/features/personal-workspace/proposal-recency.ts new file mode 100644 index 000000000..991d99074 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/proposal-recency.ts @@ -0,0 +1,33 @@ +// Which stored draft is newest is a fact about the record, not about the array +// it arrived in. `ChatActionStore.list` returns proposals ordered by +// (`updated_at`, `proposal_id`) newest first, and the workspace restores exactly +// that order; a draft created in this session is appended to the end of the map +// instead. Comparing the stored time, then the stable id, answers the same way +// for a restored list, a refreshed list, a list with a new draft at either end, +// and a list served out of contract order. + +export type ProposalRecency = { + createdAt?: string; + previewId: string; + updatedAt?: string; +}; + +export function proposalRecencyKey(proposal: ProposalRecency): [string, string] { + // Mirror the store: it sorts the raw stored strings, never a parsed date, and + // `created_at` only covers a preview that a host callback returned directly. + return [proposal.updatedAt ?? proposal.createdAt ?? "", proposal.previewId]; +} + +/** Newest first: `[...drafts].sort(compareProposalRecency)[0]` is the newest. */ +export function compareProposalRecency(a: ProposalRecency, b: ProposalRecency): number { + const [aTime, aId] = proposalRecencyKey(a); + const [bTime, bId] = proposalRecencyKey(b); + if (aTime !== bTime) return aTime < bTime ? 1 : -1; + if (aId === bId) return 0; + return aId < bId ? 1 : -1; +} + +/** Every draft but the newest, still newest first so the fold reads in order. */ +export function olderProposals(proposals: T[]): T[] { + return [...proposals].sort(compareProposalRecency).slice(1); +} diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 202ed8fdd..dc9565071 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -34,11 +34,12 @@ import { typedActionsScenario } from "./personal-workspace-browser/typed-actions import { stewardModelSettingsScenario } from "./personal-workspace-browser/steward-model-settings.mjs"; import { workspaceLocaleScenario } from "./personal-workspace-browser/workspace-locale.mjs"; import { answerPresentationScenario } from "./personal-workspace-browser/answer-presentation.mjs"; +import { newestDraftScenario } from "./personal-workspace-browser/newest-draft.mjs"; import { conversationInputScenario } from "./personal-workspace-browser/conversation-input.mjs"; import { goalActivityScenario } from "./personal-workspace-browser/goal-activity.mjs"; -const scenarioCatalog = [conversationInputScenario, goalActivityScenario, conversationActivityScenario, navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, answerPresentationScenario, loopxModeScenario, teamEvidenceScenario, managedGoalResultsScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario, workspaceLocaleScenario]; +const scenarioCatalog = [conversationInputScenario, goalActivityScenario, conversationActivityScenario, navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, answerPresentationScenario, loopxModeScenario, teamEvidenceScenario, managedGoalResultsScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario, workspaceLocaleScenario, newestDraftScenario]; const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; const scenarios = requestedScenario ? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario) diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index e7c9b947a..dd64b9464 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -1677,11 +1677,21 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true const url = new URL(route.request().url()); const goalId = url.searchParams.get("goal_id"); const contextKind = url.searchParams.get("context_kind"); - const proposals = Array.from(actionProposals.values()).filter((proposal) => { + const matching = Array.from(actionProposals.values()).filter((proposal) => { if (proposal.status === "cancelled") return false; if (goalId && (proposal.context?.goal_id ?? proposal.normalized_parameters?.goal_id) !== goalId) return false; return !contextKind || proposal.context?.kind === contextKind; }); + // `ChatActionStore.list` sorts by (`updated_at`, `proposal_id`) newest first. + // Serving insertion order instead let a positional reader pass here and keep + // the wrong draft on the first screen of the real workspace. + const proposals = matching.sort((a, b) => { + const [aTime, aId] = [a.updated_at ?? "", a.proposal_id ?? ""]; + const [bTime, bId] = [b.updated_at ?? "", b.proposal_id ?? ""]; + if (aTime !== bTime) return aTime < bTime ? 1 : -1; + if (aId === bId) return 0; + return aId < bId ? 1 : -1; + }); await route.fulfill({ contentType: "application/json", json: { ok: true, schema_version: "loopx_chat_action_list_v1", proposals }, status: 200 }); }); await page.route("**/api/actions/**", async (route) => { diff --git a/examples/personal-workspace-browser/newest-draft.mjs b/examples/personal-workspace-browser/newest-draft.mjs new file mode 100644 index 000000000..ce9eeda0d --- /dev/null +++ b/examples/personal-workspace-browser/newest-draft.mjs @@ -0,0 +1,145 @@ +import { resolve } from "node:path"; + +import { outputDir } from "./fixture.mjs"; +import { openWorkspacePage } from "./scenario-context.mjs"; + +const OLDER_SUMMARY = "Older pending draft"; +const NEWEST_SUMMARY = "Newest pending draft"; + +// A stored draft the owner has not confirmed yet. `ChatActionStore.list` orders +// these by (`updated_at`, `proposal_id`) newest first, and the fixture's list +// endpoint now serves exactly that order. +function pendingDraft({ proposalId, summary, updatedAt }) { + return { + schema_version: "loopx_chat_action_proposal_v1", + proposal_id: proposalId, + action_kind: "todo.create", + summary, + normalized_parameters: { goal_id: "product-release", text: summary }, + context: { kind: "goal", goal_id: "product-release" }, + expected_state_fingerprint: `fixture-${proposalId}`, + permission_classification: "durable_write", + validation_evidence: ["Synthetic pending draft fixture"], + available_transitions: ["apply", "cancel", "regenerate"], + status: "preview_ready", + receipt: null, + stale: null, + created_at: updatedAt, + updated_at: updatedAt, + }; +} + +const older = pendingDraft({ proposalId: "draft-older", summary: OLDER_SUMMARY, updatedAt: "2026-09-14T01:00:00Z" }); +const newer = pendingDraft({ proposalId: "draft-newer", summary: NEWEST_SUMMARY, updatedAt: "2026-09-14T03:00:00Z" }); + +export const newestDraftScenario = { + id: "newest-draft", + async run({ browser, collectCoverage, url }) { + async function openGoalChat(options = {}) { + const ui = await openWorkspacePage(browser, url, { collectCoverage, ...options }); + await ui.page.locator(".personal-goal-link", { hasText: "Product Release" }).click(); + await ui.page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); + return ui; + } + + // The newest unconfirmed draft keeps the conversation; the older one stays + // one keyboard step away instead of being the card the owner is offered first. + async function expectNewestLeads(page, note) { + const newestRow = page.locator(".personal-proposal-row", { hasText: NEWEST_SUMMARY }); + try { + await newestRow.waitFor({ state: "visible", timeout: 10_000 }); + } catch (error) { + throw new Error(`${note}: the newest draft did not lead the conversation; rows=${await page.locator(".personal-proposal-row").allInnerTexts()}; ${error.message}`); + } + const summary = page.locator(".personal-proposal-backlog > summary"); + try { + await summary.waitFor({ state: "visible", timeout: 5_000 }); + } catch (error) { + throw new Error(`${note}: the older draft was not folded behind the newest one; rows=${await page.locator(".personal-proposal-row").allInnerTexts()}; ${error.message}`); + } + if (!(await summary.innerText()).includes("另有 1 个待确认提议")) { + throw new Error(`${note}: the fold did not report the one withheld draft: ${await summary.innerText()}`); + } + const olderRow = page.locator(".personal-proposal-row", { hasText: OLDER_SUMMARY }); + if (await olderRow.isVisible()) throw new Error(`${note}: the older draft stayed on the first screen`); + await summary.click(); + await olderRow.waitFor({ state: "visible" }); + await olderRow.click(); + const drawer = page.locator('.personal-context-drawer[data-context-kind="proposal"]'); + await drawer.getByText(OLDER_SUMMARY, { exact: false }).first().waitFor({ state: "visible" }); + await page.getByRole("button", { name: /关闭详情/ }).click(); + } + + // A restored workspace, in the order the store really returns. + const restored = await openGoalChat({ apiOptions: { initialActionProposals: [older, newer] } }); + try { + await expectNewestLeads(restored.page, "restore"); + await restored.page.screenshot({ path: resolve(outputDir, "newest-draft-first-screen.png"), fullPage: false, animations: "disabled" }); + } finally { + await restored.close(); + } + + // Re-entering the Goal after a reload reaches the same conclusion. + const reloaded = await openGoalChat({ apiOptions: { initialActionProposals: [newer, older] } }); + try { + await reloaded.page.reload({ waitUntil: "networkidle" }); + await reloaded.page.getByTestId("personal-goal-home").waitFor({ state: "visible" }); + await reloaded.page.locator(".personal-goal-link", { hasText: "Product Release" }).click(); + await reloaded.page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); + await expectNewestLeads(reloaded.page, "reload"); + } finally { + await reloaded.close(); + } + + // A list served out of contract order is still judged by the stored time: + // the reader must not depend on how the records happened to be assembled. + const reversed = await openGoalChat({ + beforeGoto: async (_api, page) => { + await page.unroute("**/api/actions?**"); + await page.route("**/api/actions?**", async (route) => { + await route.fulfill({ + contentType: "application/json", + json: { ok: true, schema_version: "loopx_chat_action_list_v1", proposals: [older, newer] }, + status: 200, + }); + }); + }, + }); + try { + await expectNewestLeads(reversed.page, "reversed arrival order"); + } finally { + await reversed.close(); + } + + // A draft created in this session is appended last, the opposite of a + // restore, so it leads only because its stored time is newer. + const created = await openGoalChat({ apiOptions: { initialActionProposals: [newer, older] } }); + try { + // `protected` keeps the stop preview reviewable instead of applying it, + // and the patch gives it a stored time newer than both restored drafts. + created.api.nextLifecycleProposalPatch = { permission_classification: "protected", updated_at: "2026-09-14T04:00:00Z" }; + await created.page.getByRole("button", { name: "停止 Product Release", exact: true }).click(); + const drawer = created.page.locator('.personal-context-drawer[data-context-kind="proposal"]'); + await drawer.waitFor({ state: "visible" }); + await created.page.getByRole("button", { name: "关闭", exact: true }).click(); + await drawer.waitFor({ state: "hidden" }); + const createdRow = created.page.locator('.personal-proposal-row[data-action-kind="goal.lifecycle"]'); + await createdRow.waitFor({ state: "visible" }); + const summary = created.page.locator(".personal-proposal-backlog > summary"); + await summary.waitFor({ state: "visible" }); + if (!(await summary.innerText()).includes("另有 2 个待确认提议")) { + throw new Error(`a draft created in this session did not fold the two stored drafts: ${await summary.innerText()}`); + } + if (await created.page.locator(".personal-proposal-row", { hasText: NEWEST_SUMMARY }).isVisible()) { + throw new Error("a newer session draft left a stored draft on the first screen"); + } + } finally { + await created.close(); + } + + return { + coverageEntries: [], + note: "The newest unconfirmed draft led the first screen for a restore, a reload, a session-created draft and a reversed list; older drafts stayed one keyboard step away.", + }; + }, +}; diff --git a/examples/personal-workspace-browser/steward-journey.mjs b/examples/personal-workspace-browser/steward-journey.mjs index 8a1a7e511..6a7f174bf 100644 --- a/examples/personal-workspace-browser/steward-journey.mjs +++ b/examples/personal-workspace-browser/steward-journey.mjs @@ -217,7 +217,7 @@ export const stewardJourneyScenario = { if (!turn) await page.waitForTimeout(50); } check(Boolean(turn), "the steward prompt reaches the Goal conversation as an accepted Turn"); - const row = page.locator(".personal-proposal-row.is-ready", { hasText: "team.plan" }) + const row = page.locator('.personal-proposal-row.is-ready[data-action-kind="team.plan"]') .filter({ hasText: GOAL_ID }); await row.waitFor({ state: "visible", timeout: 15_000 }); await row.click(); diff --git a/examples/personal-workspace-browser/team-plan.mjs b/examples/personal-workspace-browser/team-plan.mjs index 4e67bf46a..0ac8bbe9e 100644 --- a/examples/personal-workspace-browser/team-plan.mjs +++ b/examples/personal-workspace-browser/team-plan.mjs @@ -94,6 +94,12 @@ function managerTeamPlanProposal() { proposal_id: MANAGER_PROPOSAL_ID, summary: MANAGER_PROPOSAL_TITLE, context: { kind: "manager", goal_id: GOAL_ID }, + // Two independently stored drafts never share a timestamp, and the Goal view + // leads with the newest unconfirmed one. The manager-channel plan is the + // earlier draft, so this Goal keeps offering its own card on the first screen + // while the manager plan stays reachable in the conversation that stored it. + created_at: "2026-09-15T18:00:00Z", + updated_at: "2026-09-15T18:00:01Z", normalized_parameters: { ...parameters, plan: { @@ -155,7 +161,7 @@ export const teamPlanScenario = { + ` body=${(await page.locator("body").innerText()).slice(0, 1500)}`, ); } - check((await row.innerText()).includes("team.plan"), "the proposal row names the team.plan action kind"); + check(await row.getAttribute("data-action-kind") === "team.plan" && (await row.innerText()).includes("团队分配"), "the proposal row is a team.plan action labelled for the owner"); await row.click(); const drawer = page.locator('.personal-context-drawer[data-context-kind="proposal"]'); @@ -238,7 +244,7 @@ export const teamPlanScenario = { ); } check( - (await managerCard.innerText()).includes("team.plan"), + await managerCard.getAttribute("data-action-kind") === "team.plan", "the manager conversation offers the team plan card it produced", ); await page.screenshot({ diff --git a/examples/personal-workspace-browser/typed-actions.mjs b/examples/personal-workspace-browser/typed-actions.mjs index 66eb35f17..a5926ab02 100644 --- a/examples/personal-workspace-browser/typed-actions.mjs +++ b/examples/personal-workspace-browser/typed-actions.mjs @@ -729,6 +729,9 @@ export const typedActionsScenario = { await page.getByRole("button", { name: "View updated Goal", exact: true }).click(); await page.getByRole("navigation", { name: "Goal view" }).getByRole("button", { name: /^(Chat|对话)$/, exact: true }).click(); const englishHeartbeatSchedule = page.locator(".personal-schedule-row", { hasText: "Goal Heartbeat" }).first(); + await page.locator(".personal-activity-summary > summary", { hasText: "Background work" }).waitFor({ state: "visible" }); + if (await englishHeartbeatSchedule.isVisible()) throw new Error("Schedules must fold under background work instead of crowding the conversation"); + await page.locator(".personal-activity-summary > summary").click(); await englishHeartbeatSchedule.waitFor({ state: "visible" }); const englishHeartbeatScheduleText = await englishHeartbeatSchedule.innerText(); if (!englishHeartbeatScheduleText.includes("1d")) throw new Error("Applied English Heartbeat lost cadence: " + englishHeartbeatScheduleText); @@ -1793,6 +1796,7 @@ export const typedActionsScenario = { await goalNavigation.getByRole("button", { name: /^(Chat|对话)$/ }).click(); const schedule = page.locator(".personal-schedule-row").first(); + if (!await schedule.isVisible()) await page.locator(".personal-activity-summary > summary").click(); for (const [label, operation] of [["立即运行", "run_now"], ["暂停", "pause"], ["改为每 2 小时", "edit"], ["停止定时检查", "stop"]]) { await schedule.click(); await page.getByText("定时检查", { exact: true }).last().waitFor({ state: "visible" });