Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8a62bce
fix(dashboard): expose scoped completed task readback
songoow Sep 5, 2026
c2cc775
fix(workspace): stabilize locale context and recover render failures
songoow Sep 5, 2026
f84bfcb
fix(workspace): refresh visit digest without resetting its baseline
songoow Sep 5, 2026
b158486
fix(workspace): prevent task overlap and add expandable completed rea…
songoow Sep 5, 2026
4032334
fix(workspace): label file actions by their actual behavior
songoow Sep 5, 2026
513669d
fix(dashboard): distinguish live loading and execution discovery fail…
songoow Sep 5, 2026
c154c77
fix(dashboard): proxy workspace report API in source development
songoow Sep 5, 2026
f52ece6
docs(workspace): explain task readback scope and recovery behavior
songoow Sep 5, 2026
73899f8
refactor(chat): own completed-task readback in a request mixin
songoow Sep 5, 2026
6e7a639
fix(workspace): align task typography to one measured scale in every …
songoow Sep 5, 2026
433fbbf
fix(workspace): keep Board as the default Tasks view
songoow Sep 5, 2026
b870c21
chore(dashboard): regenerate packaged Personal Workspace assets
songoow Sep 5, 2026
64c56fd
merge: integrate latest main into dashboard fixes
songoow Sep 5, 2026
7912d65
fix(chat): keep completed task route names distinct
songoow Sep 5, 2026
a727821
fix(dashboard): reference rebuilt merged chat bundle
songoow Sep 6, 2026
e414096
Merge branch 'main' of https://github.com/huangruiteng/loopx into cod…
songoow Sep 6, 2026
a8e6eca
refactor(workspace): share snapshot history across task views
huangruiteng Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ assert.match(
);
assert.match(
tasks,
/resizeObserverRef\.current = observer[\s\S]*\}, \[syncOverflow\]\);/,
/resizeObserverRef\.current = observer[\s\S]*\}, \[listView, syncOverflow\]\);/,
"The lane ResizeObserver lifecycle stays stable across card renders",
);
assert.match(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { z } from "zod";
import type { WorkspaceAgentTodo, WorkspaceDrawerSelection, WorkspaceGoal } from "./personal-workspace-model";
import { useWorkspaceI18n } from "./i18n";
Expand All @@ -7,14 +7,17 @@
ok: z.literal(true), total: z.number().int().nonnegative(), next_cursor: z.string().nullable(),
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),
});
const rowHeight = 148;

/** Fixed-height previews keep layout/DOM cost bounded; the drawer retains full text. */
export function CompletedTaskLane({ goal, agentId, seed, enabled, onSelect }: {
export function CompletedTaskLane({ goal, agentId, seed, enabled, listView = false, onSelect }: {

Check failure on line 12 in apps/presentation/dashboard/src/features/personal-workspace/completed-task-lane.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 21 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=huangruiteng_loopx&issues=AaB1HqFBV_OTDE-dmt4O&open=AaB1HqFBV_OTDE-dmt4O&pullRequest=3961
goal: WorkspaceGoal; agentId: string; seed: WorkspaceAgentTodo[]; enabled: boolean;
onSelect: (selection: WorkspaceDrawerSelection) => void;
listView?: boolean; onSelect: (selection: WorkspaceDrawerSelection) => void;
}) {

Check warning on line 15 in apps/presentation/dashboard/src/features/personal-workspace/completed-task-lane.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=huangruiteng_loopx&issues=AaB1HqFBV_OTDE-dmt4P&open=AaB1HqFBV_OTDE-dmt4P&pullRequest=3961
const { t } = useWorkspaceI18n();
const historyId = useId();
const [expanded, setExpanded] = useState(false);
const visible = !listView || expanded;
const rowHeight = listView ? 96 : 148;
const [rows, setRows] = useState(seed);
const [total, setTotal] = useState(agentId === "all" ? goal.doneTodoCount ?? seed.length : seed.length);
const [cursor, setCursor] = useState<string | null | undefined>(undefined);
Expand All @@ -35,7 +38,7 @@
}, []);
const needsPage = cursor === undefined || viewport.top + viewport.height >= rows.length * rowHeight - rowHeight * 2;
useEffect(() => {
if (!enabled || !needsPage || cursor === null || error || request.current) return;
if (!enabled || !visible || !needsPage || cursor === null || error || request.current) return;
const controller = new AbortController();
request.current = controller;
setBusy(true);
Expand All @@ -54,14 +57,19 @@
}).catch(() => { if (!controller.signal.aborted) setError(true); }).finally(() => {
if (!controller.signal.aborted) { request.current = null; setBusy(false); }
});
}, [enabled, needsPage, cursor, error, retry, goal.goalId, agentId, busy]);
}, [enabled, visible, needsPage, cursor, error, retry, goal.goalId, agentId, busy]);
useEffect(() => {
if (scroll.current) scroll.current.scrollTop = 0;
setViewport({ top: 0, height: scroll.current?.clientHeight ?? 600 });
setFocused(null);
}, [listView]);
const first = Math.max(0, Math.floor(viewport.top / rowHeight) - 3);
const last = Math.min(rows.length, Math.ceil((viewport.top + viewport.height) / rowHeight) + 3);
const indices = Array.from({ length: Math.max(0, last - first) }, (_, index) => first + index);
if (focused !== null && focused < rows.length && !indices.includes(focused)) indices.push(focused);
return <section className="personal-object-list personal-task-lane" data-testid="completed-task-lane">
<header><strong><i aria-hidden="true" className="personal-kanban-dot tone-done" />{t("tasks.completed")}</strong><span>{total}</span></header>
<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 })}>
return <section className={`personal-object-list personal-task-lane${listView ? " personal-completed-list" : ""}`} data-testid="completed-task-lane">
<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>
<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 })}>

Check warning on line 72 in apps/presentation/dashboard/src/features/personal-workspace/completed-task-lane.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`tabIndex` should only be declared on interactive elements.

See more on https://sonarcloud.io/project/issues?id=huangruiteng_loopx&issues=AaB1HqFBV_OTDE-dmt4R&open=AaB1HqFBV_OTDE-dmt4R&pullRequest=3961

Check warning on line 72 in apps/presentation/dashboard/src/features/personal-workspace/completed-task-lane.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <section aria-label=...>, or <section aria-labelledby=...> instead of the "region" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=huangruiteng_loopx&issues=AaB1HqFBV_OTDE-dmt4Q&open=AaB1HqFBV_OTDE-dmt4Q&pullRequest=3961
<div className="personal-completed-window" style={{ height: rows.length * rowHeight }}>
{indices.map((index) => {
const todo = rows[index]!;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -897,8 +897,8 @@ export function ContextDrawer({ agents, callbacks, goalNotifications = [], goals
) : null}
{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>}
<div className="personal-drawer-action-grid">
<button className="personal-primary-action" onClick={() => { callbacks.onOpenOutput?.(selection.item); onClose(); }} type="button"><ExternalLink size={16} />{t("common.open")}</button>
<button className="personal-secondary-action" disabled={!callbacks.onExportOutput} onClick={() => void callbacks.onExportOutput?.(selection.item)} type="button"><Download size={16} />{t("common.export")}</button>
<button className="personal-primary-action" disabled={!callbacks.onOpenOutput} onClick={() => { callbacks.onOpenOutput?.(selection.item); onClose(); }} type="button"><ExternalLink size={16} />{t("files.openConversation")}</button>
<button className="personal-secondary-action" disabled={!callbacks.onExportOutput} onClick={() => void callbacks.onExportOutput?.(selection.item)} type="button"><Download size={16} />{t("files.exportSummary")}</button>
</div>
</>
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@
count,
label,
tone,
listView = false,
}: {
children: ReactNode;
count: number;
label: string;
tone: "attention" | "done" | "progress" | "schedule";
listView?: boolean;
}) {
const labelId = useId();
const scrollRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -51,7 +53,7 @@
observedChildrenRef.current = [];
element.removeEventListener("scroll", syncOverflow);
};
}, [syncOverflow]);
}, [listView, syncOverflow]);

useEffect(() => {
const element = scrollRef.current;
Expand All @@ -64,7 +66,17 @@
for (const child of currentChildren) observer.observe(child);
observedChildrenRef.current = currentChildren;
syncOverflow();
}, [children, count, syncOverflow]);
}, [children, count, listView, syncOverflow]);

if (listView) {
if (!count && tone !== "done") return null;
return (
<details className={`personal-task-group tone-${tone}`} open>
<summary><ChevronDown size={16} /><strong>{label}</strong><span>{count}</span></summary>
<div className="personal-task-list-rows">{children}</div>
</details>
);
}

return (
<section className="personal-object-list personal-task-lane">
Expand Down Expand Up @@ -115,6 +127,7 @@
userTodos: WorkspaceModel["userTodos"];
}) {
const { t } = useWorkspaceI18n();
const [listView, setListView] = useState(false);
const [laneSelection, setLaneSelection] = useState({ goalId: "", laneId: "all" });
const selectedTodoRef = useRef<HTMLElement | null>(null);
useEffect(() => {
Expand Down Expand Up @@ -164,7 +177,14 @@
&& conversation.slice(latestUserIndex + 1).some((item) => item.message.role === "assistant" && item.message.pending);

return (
<section aria-label={t("header.tasks")} className="personal-task-board">
<section aria-label={t("header.tasks")} className={`personal-task-board${listView ? " is-list-view" : ""}`}>
<header className="personal-task-view-toolbar">
<div><strong>{t("header.tasks")}</strong><span>{t("tasks.viewDescription")}</span></div>
<div className="personal-task-view-switch" role="group" aria-label={t("tasks.viewLabel")}>

Check warning on line 183 in apps/presentation/dashboard/src/features/personal-workspace/goal-tasks-view.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <details>, <fieldset>, <optgroup>, or <address> instead of the "group" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=huangruiteng_loopx&issues=AaByKeyzDk5q6SaKI8sq&open=AaByKeyzDk5q6SaKI8sq&pullRequest=3961
<button type="button" aria-pressed={listView} onClick={() => setListView(true)}>{t("tasks.listView")}</button>
<button type="button" aria-pressed={!listView} onClick={() => setListView(false)}>{t("tasks.boardView")}</button>
</div>
</header>
{agentLanes.length > 1 ? (
<section aria-label={t("tasks.agentLaneFilter")} className="personal-task-lane-filter">
<div>
Expand Down Expand Up @@ -202,8 +222,8 @@
</footer>
</section>
) : null}
<div className="personal-task-kanban">
<TaskLane count={attentionItems.length} label={t("timeline.waitingConfirmation")} tone="attention">
<div className={listView ? "personal-task-grouped-list" : "personal-task-kanban"}>
<TaskLane listView={listView} count={attentionItems.length} label={t("timeline.waitingConfirmation")} tone="attention">
{attentionItems.map((attention) => {
const age = localizedAttentionAge(attention.updatedAt, t);
return (
Expand All @@ -219,7 +239,7 @@
})}
{!attentionItems.length ? <p className="personal-task-empty">{t("tasks.emptyConfirm")}</p> : null}
</TaskLane>
<TaskLane count={openAgentTodos.length} label={t("tasks.pendingAndRunning")} tone="progress">
<TaskLane listView={listView} count={openAgentTodos.length} label={t("tasks.pendingAndRunning")} tone="progress">
{openAgentTodos.map((todo) => {
const enriched = { ...todo, goalId: goal.goalId, goalTitle: goal.title, ownerLabel: todo.claimedBy ?? goal.agentLabel ?? goal.agentId };
const execution = executionRuns.find((item) => item.run.todoId === todo.todoId)?.run;
Expand Down Expand Up @@ -258,15 +278,15 @@
})}
{!openAgentTodos.length ? <p className="personal-task-empty">{t("tasks.emptyRunning")}</p> : null}
</TaskLane>
<TaskLane count={scheduleItems.length} label={t("tasks.scheduled")} tone="schedule">
<TaskLane listView={listView} count={scheduleItems.length} label={t("tasks.scheduled")} tone="schedule">
{scheduleItems.map((item) => (
<button key={item.id} onClick={() => onSelect({ item: item.schedule, kind: "schedule" })} type="button">
<span>◷</span><strong>{item.schedule.label}</strong><small>{item.schedule.status === "paused" ? t("schedule.paused") : t("schedule.active")}</small>
</button>
))}
{!scheduleItems.length ? <p className="personal-task-empty">{t("tasks.emptySchedules")}</p> : null}
</TaskLane>
<CompletedTaskLane key={`${goal.goalId}:${selectedLaneId}:${historyEnabled}`} goal={goal} agentId={selectedLaneId} seed={doneAgentTodos} enabled={historyEnabled} onSelect={onSelect} />
<CompletedTaskLane key={`${goal.goalId}:${selectedLaneId}:${historyEnabled}`} goal={goal} agentId={selectedLaneId} seed={doneAgentTodos} enabled={historyEnabled} listView={listView} onSelect={onSelect} />
</div>
{isEmpty ? (
<p className="personal-task-empty">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { WorkspaceI18nContext, type WorkspaceI18nValue } from "./workspace-i18n-context";

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

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

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

type WorkspaceI18nValue = {
locale: WorkspaceLocale;
setLocale: (locale: WorkspaceLocale) => void;
t: WorkspaceTranslate;
};

const WorkspaceI18nContext = createContext<WorkspaceI18nValue | null>(null);

export function WorkspaceI18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<WorkspaceLocale>(readWorkspaceLocale);

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

assert.match(page, /if \(settingsOpen\)[\s\S]*<WorkspaceSettingsPage/, "Settings replace the whole workspace shell");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,7 @@ export function PersonalWorkspacePage({
const [goalContexts, setGoalContexts] = useState<Record<string, GoalRepositoryContext>>({});
const [larkConnections, setLarkConnections] = useState<LarkGoalConnection[]>([]);
const digestInitRef = useRef(false);
const digestSinceRef = useRef(Number.NaN);
const composerRef = useRef<HTMLTextAreaElement>(null);
const channelScrollRef = useRef<HTMLDivElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
Expand Down Expand Up @@ -946,27 +947,30 @@ export function PersonalWorkspacePage({
return () => document.removeEventListener("keydown", closeOnEscape);
}, [mobileSidebarOpen]);

// Morning digest: computed once per manager-home visit, measured against the previous visit.
useEffect(() => {
if (digestInitRef.current || selectedGoalId || !items.length) return;
digestInitRef.current = true;
let since = Number.NaN;
try {
since = Date.parse(window.localStorage.getItem("loopx-pw-last-visit") ?? "");
window.localStorage.setItem("loopx-pw-last-visit", new Date().toISOString());
} catch {
// Storage unavailable: show current attention count only, without a time baseline.
if (selectedGoalId || !items.length) return;
if (!digestInitRef.current) {
digestInitRef.current = true;
try {
digestSinceRef.current = Date.parse(window.localStorage.getItem("loopx-pw-last-visit") ?? "");
window.localStorage.setItem("loopx-pw-last-visit", new Date().toISOString());
} catch {
digestSinceRef.current = Number.NaN;
}
}
const since = digestSinceRef.current;
const runs = items.filter((item): item is Extract<WorkspaceTimelineItem, { kind: "run" }> => item.kind === "run").map((item) => item.run);
const isFresh = (time?: string) => {
const parsed = Date.parse(time ?? "");
return !Number.isNaN(since) && !Number.isNaN(parsed) && parsed > since;
};
setDigest({
const nextDigest = {
attention: managerNeedsYouCount,
done: runs.filter((run) => run.status === "completed" && isFresh(run.latestActivity)).length,
failed: runs.filter((run) => (run.status === "failed" || run.status === "interrupted") && isFresh(run.latestActivity)).length,
});
};
setDigest((current) => current?.attention === nextDigest.attention
&& current.done === nextDigest.done && current.failed === nextDigest.failed ? current : nextDigest);
}, [items, managerNeedsYouCount, selectedGoalId]);

useEffect(() => {
Expand Down
Loading