Skip to content

Commit bb71b38

Browse files
fix(dashboard): restore completed-task readback and workspace reliability (#3961)
* fix(dashboard): expose scoped completed task readback Signed-off-by: song <liusongstep@gmail.com> * fix(workspace): stabilize locale context and recover render failures Signed-off-by: song <liusongstep@gmail.com> * fix(workspace): refresh visit digest without resetting its baseline Signed-off-by: song <liusongstep@gmail.com> * fix(workspace): prevent task overlap and add expandable completed readback Signed-off-by: song <liusongstep@gmail.com> * fix(workspace): label file actions by their actual behavior Signed-off-by: song <liusongstep@gmail.com> * fix(dashboard): distinguish live loading and execution discovery failures Signed-off-by: song <liusongstep@gmail.com> * fix(dashboard): proxy workspace report API in source development Signed-off-by: song <liusongstep@gmail.com> * docs(workspace): explain task readback scope and recovery behavior Signed-off-by: song <liusongstep@gmail.com> * refactor(chat): own completed-task readback in a request mixin `chat_server.py` sat at its maintainability ceiling (1503 lines, 25 `Any` names) before this PR, and the inline completed-todos handler pushed it to 1560 lines, so the control-plane maintainability ratchet failed in CI. Follow the existing `ChatStatusRequestMixin` shape: move the handler into `loopx/chat_completed_todos_api.py` as `ChatCompletedTodosRequestMixin`, which owns the route constant, the query allowlist, paging bounds, the scope check, and the read-only todo projection. The mixin depends on the handler only through the `_send_error`/`_send_json` stubs and a new `_compact_todo_record` hook, so it does not import `chat_server` back. `chat_server.py` returns to 1509 lines and 25 `Any` names; no ratchet baseline entry is edited. Two behavior corrections to the readback surfaced while moving it: - a done record without `todo_id` is dropped from the page instead of being serialized with a null id, because the dashboard response schema requires a string id and one such record would fail the whole page; - an unknown goal (`ValueError` from `list_goal_todos`) now answers 404 rather than 400; malformed queries keep 400 and unreadable sources 503. Tests patch the new owner module and cover both corrections. Validation: - python3 -m pytest tests/test_chat_server_cors.py tests/canary/test_maintainability_ratchet.py tests/control_plane/test_m6_quality_gates.py -q -> 21 passed - python3 examples/control_plane/control-plane-maintainability-ratchet-smoke.py -> ok - ruff: new module and test clean; chat_server.py findings identical to main Signed-off-by: song <liusongstep@gmail.com> * fix(workspace): align task typography to one measured scale in every lane On `main` the task surfaces render inconsistently: the `.personal-task-card` lane picks up the LoopX theme rule (13px / 600 / 18px titles, 11px metadata) while the attention, schedule, and completed lanes are plain buttons that fall through to the global defaults (16px / 700 / 24px titles, 12.8px metadata). The theme contract test asserted the card rule text, not the rendered result, so the mismatch was never caught. Make the scale explicit and identical for the board lanes, the board cards, the loopx-theme override, and the new grouped list rows, using the design system tokens: titles Body M `14px / 500 / 20px`, metadata Body S `12px / 400 / 16px`, and group headers `12px / 600 / 18px` to match the existing lane headers. Cards still do not shrink below their content and long titles still clamp. The theme contract assertion now names the Body M scale for task titles. Disclosure: this is a deliberate first-screen typography change on the Tasks view; measured before/after values and screenshots are in the PR. Validation: - node src/features/personal-workspace/workspace-theme.test.mjs -> ok - node src/features/personal-workspace/personal-workspace-contract.test.mjs -> ok - rendered metrics via Playwright: all four lanes in both views report header 12px/600/18px, title 14px/500/20px, meta 12px/400/16px Signed-off-by: song <liusongstep@gmail.com> * fix(workspace): keep Board as the default Tasks view The grouped list view stays available behind the List/Board switch, but Tasks opens in the four-lane board as it does on `main`. The packaged Personal Workspace browser smoke (a required Frontstage step) locates the progress and completed columns through the board structure, and changing the default made those assertions fail in CI. Keeping the board default also leaves the Tasks first screen unchanged, so the only first-screen change in this branch is the typography scale disclosed separately. The user guide describes the board as the default and the list as the switchable view; the task-board scroll smoke no longer needs to switch views before measuring the lanes. Validation: - node src/features/personal-workspace/personal-workspace-contract.test.mjs -> ok - node src/features/personal-workspace/workspace-theme.test.mjs -> ok - npm run smoke:personal-workspace-packaged -> personal-workspace-browser-smoke (packaged): ok Signed-off-by: song <liusongstep@gmail.com> * chore(dashboard): regenerate packaged Personal Workspace assets The Frontstage workflow rebuilds the packaged Chat dashboard with `npm run build:chat` and fails when `loopx/web/chat` differs from a clean source build. Regenerate `index.html` and the hashed bundles for the source changes in this branch. As with previous regenerations, earlier bundles stay tracked because the chat build keeps `emptyOutDir: false`. Validation: - npm run build:chat -> tsc --noEmit and vite build passed - npm run smoke:personal-workspace-packaged -> ok against these assets Signed-off-by: song <liusongstep@gmail.com> * fix(chat): keep completed task route names distinct Signed-off-by: song <liusongstep@gmail.com> * fix(dashboard): reference rebuilt merged chat bundle Signed-off-by: song <liusongstep@gmail.com> * refactor(workspace): share snapshot history across task views Signed-off-by: huangruiteng <huangrt01@163.com> --------- Signed-off-by: song <liusongstep@gmail.com> Signed-off-by: huangruiteng <huangrt01@163.com> Co-authored-by: huangruiteng <huangrt01@163.com>
1 parent bf217e1 commit bb71b38

24 files changed

Lines changed: 614 additions & 76 deletions

‎apps/presentation/dashboard/smoke/task-board-scroll-smoke.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ assert.match(
2727
);
2828
assert.match(
2929
tasks,
30-
/resizeObserverRef\.current = observer[\s\S]*\}, \[syncOverflow\]\);/,
30+
/resizeObserverRef\.current = observer[\s\S]*\}, \[listView, syncOverflow\]\);/,
3131
"The lane ResizeObserver lifecycle stays stable across card renders",
3232
);
3333
assert.match(

‎apps/presentation/dashboard/src/features/personal-workspace/completed-task-lane.tsx‎

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useRef, useState } from "react";
1+
import { useEffect, useId, useRef, useState } from "react";
22
import { z } from "zod";
33
import type { WorkspaceAgentTodo, WorkspaceDrawerSelection, WorkspaceGoal } from "./personal-workspace-model";
44
import { useWorkspaceI18n } from "./i18n";
@@ -7,14 +7,17 @@ const pageSchema = z.object({
77
ok: z.literal(true), total: z.number().int().nonnegative(), next_cursor: z.string().nullable(),
88
items: z.array(z.object({ todo_id: z.string(), text: z.string(), claimed_by: z.string().nullable(), evidence: z.string().nullable(), priority: z.string().nullable(), task_class: z.string().nullable() })).max(40),
99
});
10-
const rowHeight = 148;
1110

1211
/** Fixed-height previews keep layout/DOM cost bounded; the drawer retains full text. */
13-
export function CompletedTaskLane({ goal, agentId, seed, enabled, onSelect }: {
12+
export function CompletedTaskLane({ goal, agentId, seed, enabled, listView = false, onSelect }: {
1413
goal: WorkspaceGoal; agentId: string; seed: WorkspaceAgentTodo[]; enabled: boolean;
15-
onSelect: (selection: WorkspaceDrawerSelection) => void;
14+
listView?: boolean; onSelect: (selection: WorkspaceDrawerSelection) => void;
1615
}) {
1716
const { t } = useWorkspaceI18n();
17+
const historyId = useId();
18+
const [expanded, setExpanded] = useState(false);
19+
const visible = !listView || expanded;
20+
const rowHeight = listView ? 96 : 148;
1821
const [rows, setRows] = useState(seed);
1922
const [total, setTotal] = useState(agentId === "all" ? goal.doneTodoCount ?? seed.length : seed.length);
2023
const [cursor, setCursor] = useState<string | null | undefined>(undefined);
@@ -35,7 +38,7 @@ export function CompletedTaskLane({ goal, agentId, seed, enabled, onSelect }: {
3538
}, []);
3639
const needsPage = cursor === undefined || viewport.top + viewport.height >= rows.length * rowHeight - rowHeight * 2;
3740
useEffect(() => {
38-
if (!enabled || !needsPage || cursor === null || error || request.current) return;
41+
if (!enabled || !visible || !needsPage || cursor === null || error || request.current) return;
3942
const controller = new AbortController();
4043
request.current = controller;
4144
setBusy(true);
@@ -54,14 +57,19 @@ export function CompletedTaskLane({ goal, agentId, seed, enabled, onSelect }: {
5457
}).catch(() => { if (!controller.signal.aborted) setError(true); }).finally(() => {
5558
if (!controller.signal.aborted) { request.current = null; setBusy(false); }
5659
});
57-
}, [enabled, needsPage, cursor, error, retry, goal.goalId, agentId, busy]);
60+
}, [enabled, visible, needsPage, cursor, error, retry, goal.goalId, agentId, busy]);
61+
useEffect(() => {
62+
if (scroll.current) scroll.current.scrollTop = 0;
63+
setViewport({ top: 0, height: scroll.current?.clientHeight ?? 600 });
64+
setFocused(null);
65+
}, [listView]);
5866
const first = Math.max(0, Math.floor(viewport.top / rowHeight) - 3);
5967
const last = Math.min(rows.length, Math.ceil((viewport.top + viewport.height) / rowHeight) + 3);
6068
const indices = Array.from({ length: Math.max(0, last - first) }, (_, index) => first + index);
6169
if (focused !== null && focused < rows.length && !indices.includes(focused)) indices.push(focused);
62-
return <section className="personal-object-list personal-task-lane" data-testid="completed-task-lane">
63-
<header><strong><i aria-hidden="true" className="personal-kanban-dot tone-done" />{t("tasks.completed")}</strong><span>{total}</span></header>
64-
<div aria-label={t("tasks.completed")} className="personal-task-lane-scroll" ref={scroll} role="region" tabIndex={0} onScroll={(event) => setViewport({ top: event.currentTarget.scrollTop, height: event.currentTarget.clientHeight })}>
70+
return <section className={`personal-object-list personal-task-lane${listView ? " personal-completed-list" : ""}`} data-testid="completed-task-lane">
71+
<header>{listView ? <button type="button" aria-controls={historyId} aria-expanded={expanded} onClick={() => setExpanded(value => !value)}><span aria-hidden="true">{expanded ? "▾" : "▸"}</span> {t("tasks.completed")}</button> : <strong><i aria-hidden="true" className="personal-kanban-dot tone-done" />{t("tasks.completed")}</strong>}<span>{total}</span></header>
72+
<div id={historyId} hidden={!visible} aria-label={t("tasks.completed")} className="personal-task-lane-scroll" ref={scroll} role="region" tabIndex={0} onScroll={(event) => setViewport({ top: event.currentTarget.scrollTop, height: event.currentTarget.clientHeight })}>
6573
<div className="personal-completed-window" style={{ height: rows.length * rowHeight }}>
6674
{indices.map((index) => {
6775
const todo = rows[index]!;

‎apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -897,8 +897,8 @@ export function ContextDrawer({ agents, callbacks, goalNotifications = [], goals
897897
) : null}
898898
{selection.item.safePreview ? <pre aria-label={t("drawer.outputSafePreview")} className="personal-safe-preview">{selection.item.safePreview}</pre> : <p className="personal-preview-unavailable">{t("drawer.previewUnavailable")}</p>}
899899
<div className="personal-drawer-action-grid">
900-
<button className="personal-primary-action" onClick={() => { callbacks.onOpenOutput?.(selection.item); onClose(); }} type="button"><ExternalLink size={16} />{t("common.open")}</button>
901-
<button className="personal-secondary-action" disabled={!callbacks.onExportOutput} onClick={() => void callbacks.onExportOutput?.(selection.item)} type="button"><Download size={16} />{t("common.export")}</button>
900+
<button className="personal-primary-action" disabled={!callbacks.onOpenOutput} onClick={() => { callbacks.onOpenOutput?.(selection.item); onClose(); }} type="button"><ExternalLink size={16} />{t("files.openConversation")}</button>
901+
<button className="personal-secondary-action" disabled={!callbacks.onExportOutput} onClick={() => void callbacks.onExportOutput?.(selection.item)} type="button"><Download size={16} />{t("files.exportSummary")}</button>
902902
</div>
903903
</>
904904
) : null}

‎apps/presentation/dashboard/src/features/personal-workspace/goal-tasks-view.tsx‎

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ function TaskLane({
1515
count,
1616
label,
1717
tone,
18+
listView = false,
1819
}: {
1920
children: ReactNode;
2021
count: number;
2122
label: string;
2223
tone: "attention" | "done" | "progress" | "schedule";
24+
listView?: boolean;
2325
}) {
2426
const labelId = useId();
2527
const scrollRef = useRef<HTMLDivElement | null>(null);
@@ -51,7 +53,7 @@ function TaskLane({
5153
observedChildrenRef.current = [];
5254
element.removeEventListener("scroll", syncOverflow);
5355
};
54-
}, [syncOverflow]);
56+
}, [listView, syncOverflow]);
5557

5658
useEffect(() => {
5759
const element = scrollRef.current;
@@ -64,7 +66,17 @@ function TaskLane({
6466
for (const child of currentChildren) observer.observe(child);
6567
observedChildrenRef.current = currentChildren;
6668
syncOverflow();
67-
}, [children, count, syncOverflow]);
69+
}, [children, count, listView, syncOverflow]);
70+
71+
if (listView) {
72+
if (!count && tone !== "done") return null;
73+
return (
74+
<details className={`personal-task-group tone-${tone}`} open>
75+
<summary><ChevronDown size={16} /><strong>{label}</strong><span>{count}</span></summary>
76+
<div className="personal-task-list-rows">{children}</div>
77+
</details>
78+
);
79+
}
6880

6981
return (
7082
<section className="personal-object-list personal-task-lane">
@@ -115,6 +127,7 @@ export function GoalTasksView({
115127
userTodos: WorkspaceModel["userTodos"];
116128
}) {
117129
const { t } = useWorkspaceI18n();
130+
const [listView, setListView] = useState(false);
118131
const [laneSelection, setLaneSelection] = useState({ goalId: "", laneId: "all" });
119132
const selectedTodoRef = useRef<HTMLElement | null>(null);
120133
useEffect(() => {
@@ -164,7 +177,14 @@ export function GoalTasksView({
164177
&& conversation.slice(latestUserIndex + 1).some((item) => item.message.role === "assistant" && item.message.pending);
165178

166179
return (
167-
<section aria-label={t("header.tasks")} className="personal-task-board">
180+
<section aria-label={t("header.tasks")} className={`personal-task-board${listView ? " is-list-view" : ""}`}>
181+
<header className="personal-task-view-toolbar">
182+
<div><strong>{t("header.tasks")}</strong><span>{t("tasks.viewDescription")}</span></div>
183+
<div className="personal-task-view-switch" role="group" aria-label={t("tasks.viewLabel")}>
184+
<button type="button" aria-pressed={listView} onClick={() => setListView(true)}>{t("tasks.listView")}</button>
185+
<button type="button" aria-pressed={!listView} onClick={() => setListView(false)}>{t("tasks.boardView")}</button>
186+
</div>
187+
</header>
168188
{agentLanes.length > 1 ? (
169189
<section aria-label={t("tasks.agentLaneFilter")} className="personal-task-lane-filter">
170190
<div>
@@ -202,8 +222,8 @@ export function GoalTasksView({
202222
</footer>
203223
</section>
204224
) : null}
205-
<div className="personal-task-kanban">
206-
<TaskLane count={attentionItems.length} label={t("timeline.waitingConfirmation")} tone="attention">
225+
<div className={listView ? "personal-task-grouped-list" : "personal-task-kanban"}>
226+
<TaskLane listView={listView} count={attentionItems.length} label={t("timeline.waitingConfirmation")} tone="attention">
207227
{attentionItems.map((attention) => {
208228
const age = localizedAttentionAge(attention.updatedAt, t);
209229
return (
@@ -219,7 +239,7 @@ export function GoalTasksView({
219239
})}
220240
{!attentionItems.length ? <p className="personal-task-empty">{t("tasks.emptyConfirm")}</p> : null}
221241
</TaskLane>
222-
<TaskLane count={openAgentTodos.length} label={t("tasks.pendingAndRunning")} tone="progress">
242+
<TaskLane listView={listView} count={openAgentTodos.length} label={t("tasks.pendingAndRunning")} tone="progress">
223243
{openAgentTodos.map((todo) => {
224244
const enriched = { ...todo, goalId: goal.goalId, goalTitle: goal.title, ownerLabel: todo.claimedBy ?? goal.agentLabel ?? goal.agentId };
225245
const execution = executionRuns.find((item) => item.run.todoId === todo.todoId)?.run;
@@ -258,15 +278,15 @@ export function GoalTasksView({
258278
})}
259279
{!openAgentTodos.length ? <p className="personal-task-empty">{t("tasks.emptyRunning")}</p> : null}
260280
</TaskLane>
261-
<TaskLane count={scheduleItems.length} label={t("tasks.scheduled")} tone="schedule">
281+
<TaskLane listView={listView} count={scheduleItems.length} label={t("tasks.scheduled")} tone="schedule">
262282
{scheduleItems.map((item) => (
263283
<button key={item.id} onClick={() => onSelect({ item: item.schedule, kind: "schedule" })} type="button">
264284
<span>◷</span><strong>{item.schedule.label}</strong><small>{item.schedule.status === "paused" ? t("schedule.paused") : t("schedule.active")}</small>
265285
</button>
266286
))}
267287
{!scheduleItems.length ? <p className="personal-task-empty">{t("tasks.emptySchedules")}</p> : null}
268288
</TaskLane>
269-
<CompletedTaskLane key={`${goal.goalId}:${selectedLaneId}:${historyEnabled}`} goal={goal} agentId={selectedLaneId} seed={doneAgentTodos} enabled={historyEnabled} onSelect={onSelect} />
289+
<CompletedTaskLane key={`${goal.goalId}:${selectedLaneId}:${historyEnabled}`} goal={goal} agentId={selectedLaneId} seed={doneAgentTodos} enabled={historyEnabled} listView={listView} onSelect={onSelect} />
270290
</div>
271291
{isEmpty ? (
272292
<p className="personal-task-empty">

‎apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx‎

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import {
2-
createContext,
32
useContext,
43
useEffect,
54
useMemo,
65
useState,
76
type ReactNode,
87
} from "react";
8+
import { WorkspaceI18nContext, type WorkspaceI18nValue } from "./workspace-i18n-context";
99

1010
export const workspaceLocaleStorageKey = "loopx-pw-locale";
1111

@@ -88,10 +88,10 @@ const en = {
8888
"conversation.replying": "Replying",
8989
"conversation.title": "Manager conversation",
9090
"conversation.toTask": "Convert to Task",
91-
"digest.away": "While you were away",
92-
"digest.completed": "completed",
93-
"digest.failed": "issues",
94-
"digest.needsYou": "need confirmation",
91+
"digest.away": "Runs since your previous visit",
92+
"digest.completed": "new completions",
93+
"digest.failed": "new failed/interrupted runs",
94+
"digest.needsYou": "currently need confirmation",
9595
"feedback.applying": "Running: {title}",
9696
"feedback.cancelFailed": "Cancel failed: {error}",
9797
"feedback.completed": "Completed: {title}",
@@ -804,6 +804,14 @@ const en = {
804804
"tasks.chatViewReply": "View reply",
805805
"tasks.convertToTask": "Convert to Task",
806806
"tasks.completed": "Completed",
807+
"runs.discoveryPartial": "Some execution details are unavailable. Reconnecting; task summaries are not live execution status.",
808+
"runs.discoveryOffline": "Execution service unavailable. Reconnecting; retained task summaries may be stale.",
809+
"files.openConversation": "Go to conversation",
810+
"files.exportSummary": "Export summary",
811+
"tasks.viewDescription": "Decisions first, then work and recent completions",
812+
"tasks.viewLabel": "Task view",
813+
"tasks.listView": "List",
814+
"tasks.boardView": "Board",
807815
"tasks.completedSummary": "{count} tasks completed. Recent details are projected by the control plane when needed.",
808816
"tasks.emptyCompleted": "No completed tasks yet.",
809817
"tasks.emptyConfirm": "No tasks waiting for confirmation.",
@@ -914,10 +922,10 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
914922
"conversation.replying": "正在回复",
915923
"conversation.title": "管家对话",
916924
"conversation.toTask": "转为 Task",
917-
"digest.away": "你不在的时候",
918-
"digest.completed": "完成",
919-
"digest.failed": "异常",
920-
"digest.needsYou": "等你确认",
925+
"digest.away": "自上次访问以来的运行记录",
926+
"digest.completed": "新增完成运行",
927+
"digest.failed": "新增失败或中断",
928+
"digest.needsYou": "当前待确认",
921929
"feedback.applying": "正在执行:{title}",
922930
"feedback.cancelFailed": "取消失败:{error}",
923931
"feedback.completed": "已完成:{title}",
@@ -1630,6 +1638,14 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
16301638
"tasks.chatViewReply": "查看回复",
16311639
"tasks.convertToTask": "转为 Task",
16321640
"tasks.completed": "已完成",
1641+
"runs.discoveryPartial": "部分执行详情暂不可用,正在重连;任务摘要不代表实时执行状态。",
1642+
"runs.discoveryOffline": "执行服务暂不可用,正在重连;保留的任务摘要可能已过时。",
1643+
"files.openConversation": "前往会话",
1644+
"files.exportSummary": "导出摘要",
1645+
"tasks.viewDescription": "先处理待确认,再查看工作与完成记录",
1646+
"tasks.viewLabel": "任务视图",
1647+
"tasks.listView": "列表",
1648+
"tasks.boardView": "看板",
16331649
"tasks.completedSummary": "已完成 {count} 项,近期完成明细由控制面按需投影。",
16341650
"tasks.emptyCompleted": "还没有完成的任务。",
16351651
"tasks.emptyConfirm": "没有待确认的任务。",
@@ -1684,14 +1700,6 @@ export function readWorkspaceLocale(): WorkspaceLocale {
16841700

16851701
export type WorkspaceTranslate = (key: WorkspaceMessageKey, values?: Record<string, string | number>) => string;
16861702

1687-
type WorkspaceI18nValue = {
1688-
locale: WorkspaceLocale;
1689-
setLocale: (locale: WorkspaceLocale) => void;
1690-
t: WorkspaceTranslate;
1691-
};
1692-
1693-
const WorkspaceI18nContext = createContext<WorkspaceI18nValue | null>(null);
1694-
16951703
export function WorkspaceI18nProvider({ children }: { children: ReactNode }) {
16961704
const [locale, setLocaleState] = useState<WorkspaceLocale>(readWorkspaceLocale);
16971705

‎apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -345,8 +345,8 @@ assert.match(i18n, /当前本地工作区(未绑定 Repository)/, "Chinese w
345345
assert.doesNotMatch(model, /kind: "agent"/, "The drawer model omits the read-only Agent settings variant");
346346
assert.match(
347347
dashboard,
348-
/statusRequestActive = source\.kind === "example"[\s\S]*?&& Boolean\(activeStatusRequestUrl\)[\s\S]*?&& Boolean\(loadError && requestedStatusUrl\)/,
349-
"Initial load and refresh keep the workspace shell visible, while failed authoritative status requests surface recovery",
348+
/statusRequestActive = source\.kind === "example"\s*&& !exampleModeRequested;/,
349+
"Initial real-status loading must not display bundled example tasks; explicit example mode remains available",
350350
);
351351

352352
assert.match(page, /if \(settingsOpen\)[\s\S]*<WorkspaceSettingsPage/, "Settings replace the whole workspace shell");

‎apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx‎

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,7 @@ export function PersonalWorkspacePage({
727727
const [goalContexts, setGoalContexts] = useState<Record<string, GoalRepositoryContext>>({});
728728
const [larkConnections, setLarkConnections] = useState<LarkGoalConnection[]>([]);
729729
const digestInitRef = useRef(false);
730+
const digestSinceRef = useRef(Number.NaN);
730731
const composerRef = useRef<HTMLTextAreaElement>(null);
731732
const channelScrollRef = useRef<HTMLDivElement>(null);
732733
const imageInputRef = useRef<HTMLInputElement>(null);
@@ -946,27 +947,30 @@ export function PersonalWorkspacePage({
946947
return () => document.removeEventListener("keydown", closeOnEscape);
947948
}, [mobileSidebarOpen]);
948949

949-
// Morning digest: computed once per manager-home visit, measured against the previous visit.
950950
useEffect(() => {
951-
if (digestInitRef.current || selectedGoalId || !items.length) return;
952-
digestInitRef.current = true;
953-
let since = Number.NaN;
954-
try {
955-
since = Date.parse(window.localStorage.getItem("loopx-pw-last-visit") ?? "");
956-
window.localStorage.setItem("loopx-pw-last-visit", new Date().toISOString());
957-
} catch {
958-
// Storage unavailable: show current attention count only, without a time baseline.
951+
if (selectedGoalId || !items.length) return;
952+
if (!digestInitRef.current) {
953+
digestInitRef.current = true;
954+
try {
955+
digestSinceRef.current = Date.parse(window.localStorage.getItem("loopx-pw-last-visit") ?? "");
956+
window.localStorage.setItem("loopx-pw-last-visit", new Date().toISOString());
957+
} catch {
958+
digestSinceRef.current = Number.NaN;
959+
}
959960
}
961+
const since = digestSinceRef.current;
960962
const runs = items.filter((item): item is Extract<WorkspaceTimelineItem, { kind: "run" }> => item.kind === "run").map((item) => item.run);
961963
const isFresh = (time?: string) => {
962964
const parsed = Date.parse(time ?? "");
963965
return !Number.isNaN(since) && !Number.isNaN(parsed) && parsed > since;
964966
};
965-
setDigest({
967+
const nextDigest = {
966968
attention: managerNeedsYouCount,
967969
done: runs.filter((run) => run.status === "completed" && isFresh(run.latestActivity)).length,
968970
failed: runs.filter((run) => (run.status === "failed" || run.status === "interrupted") && isFresh(run.latestActivity)).length,
969-
});
971+
};
972+
setDigest((current) => current?.attention === nextDigest.attention
973+
&& current.done === nextDigest.done && current.failed === nextDigest.failed ? current : nextDigest);
970974
}, [items, managerNeedsYouCount, selectedGoalId]);
971975

972976
useEffect(() => {

0 commit comments

Comments
 (0)