Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@

### Fixed

- #578 / #579 / #580:目标与项目支持补充或编辑验收标准,冲突保留草稿并可加载最新标准;阻塞目标只跳转明确绑定的会话;Agent 看板随任务事件和重连刷新,丢弃过期响应,加载失败保留快照并可重试。

- 桌面标题栏按窗口所在系统显示控制钮:Windows 使用右侧原生最小化、最大化/还原与关闭,macOS 保留左侧红黄绿;WSL 后端不影响 Windows 窗口样式,原生控制区同步明暗与自定义主题。
- 项目看板派工复用员工活动会话,结果按原 session/turn 回看;旋转会话与控制面重启后仍可定位,旧版无 session 结果提供独立只读入口。
- 员工执行期间可调整下一轮模型,当前执行保持启动时的模型快照;Qoder 活动轨迹展示公开工具参数和完成详情。
Expand Down
78 changes: 78 additions & 0 deletions apps/desktop/renderer/src/goals/GoalCriteriaDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useEffect, useRef, useState } from "react";
import { Alert, Button, Input, Modal } from "antd";
import { useT } from "@roleweave/ui";
import { validateGoalUpdateRequest, type Goal } from "@roleweave/shared/goals";

interface GoalCriteriaDialogProps {
goal: Goal;
onClose: () => void;
onSaved: () => Promise<void>;
}

/** Keep the reviewed criteria and their version together until explicitly reloaded. */
export function GoalCriteriaDialog({ goal, onClose, onSaved }: GoalCriteriaDialogProps) {
const t = useT();
const [criteria, setCriteria] = useState([...goal.acceptanceCriteria]);
const [version, setVersion] = useState(goal.updatedAt);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [conflict, setConflict] = useState(false);
const alive = useRef(true);
const lock = useRef(false);
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
const nextCriteria = criteria.map((item) => item.trim()).filter(Boolean);
const valid = nextCriteria.length > 0 && validateGoalUpdateRequest({ acceptanceCriteria: nextCriteria, expectedUpdatedAt: version }).ok;

const reload = async () => {
if (lock.current) return;
lock.current = true; setBusy(true); setError(null);
try {
const response = await window.owb.goal(goal.goalId);
if (!alive.current) return;
if (response.status !== 200) throw new Error(t("goals.loadError"));
setCriteria([...response.body.goal.acceptanceCriteria]);
setVersion(response.body.goal.updatedAt);
setConflict(false);
} catch {
if (alive.current) setError(t("goals.loadError"));
} finally {
lock.current = false;
if (alive.current) setBusy(false);
}
};
const save = async () => {
if (lock.current || !valid || conflict) return;
lock.current = true; setBusy(true); setError(null);
try {
const response = await window.owb.updateGoal({ goalId: goal.goalId, acceptanceCriteria: nextCriteria, expectedUpdatedAt: version });
if (!alive.current) return;
if (response.status !== 200) {
setConflict(response.status === 409);
setError(t(response.status === 409 ? "goals.criteriaConflict" : "goals.criteriaSaveError"));
return;
}
await onSaved();
if (alive.current) onClose();
} catch {
if (alive.current) setError(t("goals.criteriaSaveError"));
} finally {
lock.current = false;
if (alive.current) setBusy(false);
}
};
return <Modal title={t("goals.criteriaEdit")} open onCancel={() => { if (!busy) onClose(); }}
mask={{ closable: !busy }} closable={!busy} onOk={() => void save()} confirmLoading={busy}
okButtonProps={{ disabled: !valid || conflict }} okText={t("goals.criteriaSave")} cancelText={t("dlg.cancel")}>
<p>{t("goals.criteriaEditHint")}</p>
{error && <Alert type="error" showIcon title={error} />}
{conflict && <><p>{t("goals.criteriaReloadHint")}</p><Button disabled={busy} onClick={() => void reload()}>{t("goals.criteriaReload")}</Button></>}
{criteria.map((criterion, index) => <div key={index} className="owb-goal-create__criteria-row">
<Input.TextArea rows={2} maxLength={4096} value={criterion} disabled={busy}
aria-label={t("goals.criteriaItem", { index: index + 1 })}
onChange={(event) => setCriteria((current) => current.map((item, itemIndex) => itemIndex === index ? event.target.value : item))} />
<Button disabled={busy} onClick={() => setCriteria((current) => current.filter((_, itemIndex) => itemIndex !== index))}
aria-label={t("goals.criteriaRemoveItem", { index: index + 1 })}>{t("goals.criteriaRemove")}</Button>
</div>)}
<Button disabled={busy || criteria.length >= 16} onClick={() => setCriteria((current) => [...current, ""])}>{t("goals.criteriaAdd")}</Button>
</Modal>;
}
69 changes: 45 additions & 24 deletions apps/desktop/renderer/src/goals/GoalsModule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import "./goals-project.css";
import "./goals-workspace.css";
import { GoalEvidence } from "./GoalEvidence.js";
import { GoalCreateDialog } from "./GoalCreateDialog.js";
import { GoalCriteriaDialog } from "./GoalCriteriaDialog.js";

interface GoalsModuleProps {
workspaceOpen: boolean;
Expand Down Expand Up @@ -69,6 +70,8 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p
const ActivitySection = projectMode ? "section" : "details";
const [view, setView] = useState<"goals" | "board">("goals");
const [tasks, setTasks] = useState<AgentTask[]>([]);
const [taskError, setTaskError] = useState<string | null>(null);
const [tasksLoading, setTasksLoading] = useState(false);
const [showTaskCreate, setShowTaskCreate] = useState(false);
const [taskTitle, setTaskTitle] = useState("");
const [taskTarget, setTaskTarget] = useState("");
Expand All @@ -85,6 +88,7 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p
const [detailError, setDetailError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [criteriaGoalId, setCriteriaGoalId] = useState<string | null>(null);
const [mutating, setMutating] = useState(false);
const mutationLock = useRef(false);
const [query, setQuery] = useState("");
Expand All @@ -93,13 +97,15 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p
const alive = useRef(true);
const listVersion = useRef(0);
const detailVersion = useRef(0);
const tasksVersion = useRef(0);

useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
listVersion.current += 1;
detailVersion.current += 1;
tasksVersion.current += 1;
};
}, []);

Expand All @@ -110,6 +116,7 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p
setDetail(null);
setDetailError(null);
setActionError(null);
setCriteriaGoalId(null);
}
selectedRef.current = id;
setSelectedId(id);
Expand Down Expand Up @@ -186,19 +193,31 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p
useEffect(() => {
void loadGoals();
}, [loadGoals]);
const reloadTasks = useCallback(async () => {
if (!workspaceOpen || typeof window.owb.tasks !== "function") return;
const version = ++tasksVersion.current;
const isCurrent = () => alive.current && version === tasksVersion.current;
setTasksLoading(true);
setTaskError(null);
try {
const response = await window.owb.tasks();
if (!isCurrent()) return;
if (response.status !== 200) throw new Error(errorMessage(response.body, t("tasks.loadError")));
setTasks(response.body.tasks);
} catch (cause) {
if (isCurrent()) setTaskError(cause instanceof Error ? cause.message : t("tasks.loadError"));
} finally {
if (isCurrent()) setTasksLoading(false);
}
}, [workspaceOpen, t]);
useEffect(() => {
if (!workspaceOpen || view !== "board" || typeof window.owb.tasks !== "function") return;
let cancelled = false;
void window.owb.tasks().then((response) => {
if (!cancelled && response.status === 200) setTasks(response.body.tasks);
if (view !== "board") return;
void reloadTasks();
const off = window.owb.onSseStatus?.((state) => {
if (state === "connected") void reloadTasks();
});
return () => { cancelled = true; };
}, [workspaceOpen, view]);
const reloadTasks = useCallback(async () => {
if (typeof window.owb.tasks !== "function") return;
const response = await window.owb.tasks();
if (response.status === 200) setTasks(response.body.tasks);
}, []);
return () => { tasksVersion.current += 1; off?.(); };
}, [view, reloadTasks]);
const createTask = useCallback(async () => {
const owner = ownerPositionId;
if (!owner || !taskTarget || !taskTitle.trim()) return;
Expand Down Expand Up @@ -226,9 +245,10 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p
) {
void loadGoals();
if (selectedRef.current) void loadDetail(selectedRef.current);
if (view === "board") void reloadTasks();
}
});
}, [workspaceOpen, loadGoals, loadDetail]);
}, [workspaceOpen, loadGoals, loadDetail, view, reloadTasks]);

// Refresh execution evidence without unmounting an open task editor.
useEffect(() => {
Expand Down Expand Up @@ -318,6 +338,7 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p
setQuery("");
setStatusFilter("all");
};
const boundBranch = detail?.goal.branches.find((branch) => branch.positionId && branch.sessionId);
const empty = !loading && !error && goals.length === 0;
const createButton = (
<AntButton
Expand Down Expand Up @@ -358,6 +379,8 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p
</div>
) : !projectMode && view === "board" ? (
<div className="owb-task-board" aria-label={t("tasks.boardAria")}>
{taskError && <div className="owb-goals-error" role="alert">{taskError}{" "}<AntButton onClick={() => void reloadTasks()}>{t("hire.retry")}</AntButton></div>}
{tasksLoading && <p role="status">{t("tasks.loading")}</p>}
{(["queued", "active", "waiting", "done"] as const).map((column) => (
<section className="owb-task-column" key={column}>
<header><strong>{t(`tasks.column.${column}`)}</strong><span>{tasks.filter((task) => column === "done" ? ["done", "declined", "failed"].includes(task.status) : task.status === column).length}</span></header>
Expand Down Expand Up @@ -616,18 +639,11 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p
</div>
{detail.healthOverlay === "blocked" ? (
<div className="owb-goals-health-actions">
{detail.goal.branches.some((branch) => branch.positionId) ? (
{boundBranch && onOpenBoundSession ? (
<AntButton
data-testid="goals-health-open-turn"
onClick={() => {
const branch = detail.goal.branches.find(
(item) => item.positionId,
);
if (branch?.positionId)
onOpenBoundSession?.(
branch.positionId,
branch.sessionId,
);
onOpenBoundSession(boundBranch.positionId!, boundBranch.sessionId!);
}}
>
{t("goals.health.openBoundTurn")}
Expand All @@ -641,7 +657,7 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p
{t("goals.health.openApprovals")}
</AntButton>
) : null}
{!detail.goal.branches.some((branch) => branch.positionId) &&
{!(boundBranch && onOpenBoundSession) &&
!onOpenApprovals ? (
<p data-testid="goals-health-blocked-empty">
{t("goals.health.blockedNoTarget")}
Expand Down Expand Up @@ -674,9 +690,9 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p
{detail.goal.description}
</p>
</section>
{detail.goal.acceptanceCriteria.length > 0 && (
<section>
<h3>{t(projectMode ? "project.form.criteria" : "goals.criteria")}</h3>
<AntButton size="small" disabled={mutating} onClick={() => setCriteriaGoalId(detail.goal.goalId)}>{t("goals.criteriaEdit")}</AntButton>
<ol className={!projectMode ? "owb-goals-criteria" : undefined}>
{detail.goal.acceptanceCriteria.map(
(criterion, index) => (
Expand All @@ -685,7 +701,6 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p
)}
</ol>
</section>
)}
{!projectMode && <GoalEvidence branches={detail.goal.branches} positionNames={positionNames} onOpenBoundSession={onOpenBoundSession} />}
{projectMode && detail.goal.branches.length > 0 && (
<section>
Expand Down Expand Up @@ -739,6 +754,12 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p
void loadGoals(id);
}}
/>
{detail && criteriaGoalId === detail.goal.goalId && <GoalCriteriaDialog
key={detail.goal.goalId}
goal={detail.goal}
onClose={() => setCriteriaGoalId(null)}
onSaved={async () => { await Promise.all([loadGoals(), loadDetail(detail.goal.goalId)]); }}
/>}
</>
)}
</section>
Expand Down
Loading
Loading