From 090198ea0067a23e6c95960c7c2a3fc1e12d9cd1 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:12:39 +0800 Subject: [PATCH 1/4] feat(app): let the conversation lead the Goal Chat view Scheduled checks, heartbeats and routine runs fold into one Background work line. Drafts use owner-facing kind and status labels instead of raw action strings, and only the newest draft awaiting the owner stays open. Needs-you items, waiting or failed runs and non-draft proposals remain visible. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Co-authored-by: Cursor --- .../personal-workspace/channel-timeline.tsx | 38 ++++++++++---- .../src/features/personal-workspace/i18n.tsx | 52 +++++++++++++++++++ .../personal-workspace/personal-workspace.css | 10 ++-- 3 files changed, 84 insertions(+), 16 deletions(-) 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..bf51fe44d 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx @@ -144,17 +144,27 @@ 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. + const readyProposalItems = activeProposalItems.filter(item => item.proposal.status === "ready"); + const foldedProposalIds = new Set(readyProposalItems.slice(0, -1).map(item => item.id)); + const visibleProposalItems = activeProposalItems.filter(item => !foldedProposalIds.has(item.id)); function renderItem(item: WorkspaceTimelineItem) { if (item.kind === "attention") { @@ -172,10 +182,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 +217,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 +228,13 @@ export function ChannelTimeline({
{gatedItems.map(renderItem)}
) : null} - {activeProposalItems.map(renderItem)} + {foldedProposalIds.size ? ( +
+ +
{readyProposalItems.filter(item => foldedProposalIds.has(item.id)).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.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; } From 3afdfe4504d468582d0a1e1a3f76e97336629627 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:12:39 +0800 Subject: [PATCH 2/4] test(app): select proposals by action kind and open background work Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Co-authored-by: Cursor --- examples/personal-workspace-browser/steward-journey.mjs | 2 +- examples/personal-workspace-browser/team-plan.mjs | 4 ++-- examples/personal-workspace-browser/typed-actions.mjs | 4 ++++ 3 files changed, 7 insertions(+), 3 deletions(-) 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..f58cf2eec 100644 --- a/examples/personal-workspace-browser/team-plan.mjs +++ b/examples/personal-workspace-browser/team-plan.mjs @@ -155,7 +155,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 +238,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" }); From e857fb91574cfedb52aaace084a12cccd1ad6611 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:42:12 +0800 Subject: [PATCH 3/4] fix(app): lead the Goal Chat with the newest pending draft `ChannelTimeline` folded every ready draft except the last one in the array and kept that one on the first screen. The array order is not recency: the store returns proposals newest-first by (`updated_at`, `proposal_id`), the workspace restores exactly that order, and a draft created in this session is appended last. A restored workspace therefore folded the newest draft and offered the oldest one -- the opposite of the intended "the conversation leads" reading, and the card the owner would confirm first was the stale one. Read recency from the record instead of the position. The stored times now reach the row through the typed adapter, a focused `proposal-recency.ts` owns the comparison (mirroring the store's own tie-break so the answer matches a real list), and `dedupeProposals` keeps the newest record for a subject rather than whichever duplicate happened to be last. The team-plan fixture gave two stored drafts the same timestamp, which real stored actions never do and which made the Goal view nondeterministic; the manager-channel plan is now explicitly the earlier draft. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- apps/presentation/dashboard/package.json | 5 +- .../personal-workspace/channel-timeline.tsx | 10 +++- .../personal-workspace-model.ts | 7 +++ .../personal-workspace-page.tsx | 9 +++- .../proposal-recency.test.mjs | 48 +++++++++++++++++++ .../personal-workspace/proposal-recency.ts | 33 +++++++++++++ .../personal-workspace-browser/team-plan.mjs | 6 +++ 7 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/proposal-recency.test.mjs create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/proposal-recency.ts 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 bf51fe44d..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) { @@ -162,8 +163,13 @@ export function ChannelTimeline({ 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 foldedProposalIds = new Set(readyProposalItems.slice(0, -1).map(item => item.id)); + 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) { @@ -231,7 +237,7 @@ export function ChannelTimeline({ {foldedProposalIds.size ? (
-
{readyProposalItems.filter(item => foldedProposalIds.has(item.id)).map(renderItem)}
+
{foldedProposalItems.map(renderItem)}
) : null} {visibleProposalItems.map(renderItem)} 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/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/team-plan.mjs b/examples/personal-workspace-browser/team-plan.mjs index f58cf2eec..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: { From 727b9de1470ff192518337748bd2b7441a9b90b6 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:42:37 +0800 Subject: [PATCH 4/4] test(app): serve the store's real proposal order and pin the rule The browser fixture answered `/api/actions` in insertion order, which no real `ChatActionStore.list` ever returns, so the seventeen scenarios could pass while the workspace read the wrong draft as newest. Sort the fixture's list response by (`updated_at`, `proposal_id`) newest first, exactly as the store does. Add a `newest-draft` scenario over two real stored drafts: a restore in store order, a reload and re-entry, a list served deliberately out of contract order, and a draft created in this session appended last. Each case asserts the newest draft keeps the conversation, the older one is folded with its count, and the fold still opens the drawer by keyboard. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- examples/personal-workspace-browser-smoke.mjs | 3 +- .../personal-workspace-browser/fixture.mjs | 12 +- .../newest-draft.mjs | 145 ++++++++++++++++++ 3 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 examples/personal-workspace-browser/newest-draft.mjs 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.", + }; + }, +};