diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-results.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-results.tsx
index e67990d86d..8fc0e12274 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-results.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-results.tsx
@@ -1,31 +1,35 @@
-import {Check, FileText, RefreshCw, TriangleAlert} from "lucide-react";
+import {RefreshCw, TriangleAlert} from "lucide-react";
import {ContextHelp} from "./context-help";
import {useEffect, useRef, useState} from "react";
-import {fetchLoopXTeamWork, readLoopXTeamWork, type DelegationInventory, type DelegationReadback} from "../../data/chat";
+import {delegationStateLabel, fetchLoopXTeamWork, readLoopXTeamWork, type DelegationInventory, type DelegationReadback} from "../../data/chat";
import {TeamArtifactReport, isMarkdownArtifact, type TeamArtifact} from "./team-artifact-content";
import {GoalTeamLineage} from "./goal-team-lineage";
+import {ResultCatalogReturn, ResultCatalogRow, useResultCatalogFocus} from "./result-catalog";
type Selection = {operationId: string; ref?: string; sha256?: string};
const readableRows = (page: DelegationInventory) => page.items.filter(row =>
row.operation_id && row.status === "accepted" && row.current_use?.state !== "unavailable" && !row.recovery_required && row.artifacts?.length);
const AUTO_DISCOVERY_PAGES = 3;
const MAX_INSPECTED_PAGES = 10;
+const resultKey = (operationId: string, artifact: {ref: string; sha256: string}) => JSON.stringify([operationId, artifact.ref, artifact.sha256]);
/** Original-conversation result reader and explicit requester adoption. */
-export function GoalTeamResults({sessionId, zh, refreshKey}: {sessionId: string; zh: boolean; refreshKey: string}) {
+export function GoalTeamResults({sessionId, zh, refreshKey, catalogOnly = false}: {sessionId: string; zh: boolean; refreshKey: string; catalogOnly?: boolean}) {
const [pages, setPages] = useState([]);
const [selection, setSelection] = useState<{result: DelegationReadback; artifact: TeamArtifact} | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
+ const [reading, setReading] = useState(false);
const generation = useRef(0);
const chosen = useRef(null);
const inspectedPages = useRef(1);
const focusReport = useRef(false);
- const report = useRef(null);
- useEffect(() => {chosen.current = null; inspectedPages.current = 1;}, [sessionId]);
- useEffect(() => {void list(); return () => {generation.current++;};}, [sessionId, refreshKey]);
+ const origin = useRef(null);
+ const focus = useResultCatalogFocus();
+ useEffect(() => {chosen.current = null; origin.current = null; inspectedPages.current = 1; setReading(false); setPages([]);}, [sessionId, catalogOnly]);
+ useEffect(() => {void list(); return () => {generation.current++; focus.cancel();};}, [sessionId, refreshKey, catalogOnly]);
useEffect(() => {
- if (selection && focusReport.current) {report.current?.focus(); focusReport.current = false;}
+ if (selection && focusReport.current) {focus.reader(); focusReport.current = false;}
}, [selection]);
const changed = zh ? "产物或验收已变化,未展示旧报告。请重新选择可核验的产物。" : "Artifact or acceptance changed. Previous report cleared; select a verified artifact again.";
async function load(operationId: string, expected: Selection | null, current: number) {
@@ -44,10 +48,12 @@ export function GoalTeamResults({sessionId, zh, refreshKey}: {sessionId: string;
}
async function list(nextPage = false) {
const current = ++generation.current;
- if (nextPage) chosen.current = null;
+ focus.cancel();
+ if (nextPage) {chosen.current = null; origin.current = null; setReading(false);}
const minimumPages = Math.min(MAX_INSPECTED_PAGES, inspectedPages.current + (nextPage ? 1 : 0));
focusReport.current = false;
- setBusy(true); setError(""); setPages([]); setSelection(null);
+ setBusy(true); setError(""); setSelection(null);
+ let inventoryRead = false;
try {
const observed: DelegationInventory[] = [];
let nextCursor: string | undefined;
@@ -64,23 +70,30 @@ export function GoalTeamResults({sessionId, zh, refreshKey}: {sessionId: string;
}
inspectedPages.current = observed.length;
setPages(observed);
+ inventoryRead = true;
// Keep the reader's exact version. A refresh must never silently replace
// it with a newer artifact or move to a different member's result.
let target = chosen.current;
- if (!target) {
+ if (!target && !catalogOnly) {
const first = observed.flatMap(readableRows)[0];
const artifact = first?.artifacts?.find(row => isMarkdownArtifact(row.ref)) ?? first?.artifacts?.[0];
if (first?.operation_id && artifact) target = {operationId: first.operation_id, ...artifact};
}
if (target) {chosen.current = target; await load(target.operationId, target, current);}
} catch (failure) {
- if (current === generation.current) setError(`${zh ? "无法核验,已清除上次报告。" : "Cannot verify; previous report cleared."} ${String(failure)}`);
+ if (current === generation.current) {
+ if (!inventoryRead) setPages([]);
+ setError(`${zh ? "无法核验,已清除上次报告。" : "Cannot verify; previous report cleared."} ${String(failure)}`);
+ }
} finally {if (current === generation.current) setBusy(false);}
}
async function read(operationId: string, expected?: {ref: string; sha256: string}) {
const current = ++generation.current;
+ focus.cancel();
focusReport.current = true;
+ setReading(true);
setBusy(true); setError(""); setSelection(null);
+ if (catalogOnly) focus.reader();
const target = {operationId, ...expected};
// Keep failed explicit selections too; a background refresh cannot choose
// a different result and mask the failed readback.
@@ -89,7 +102,15 @@ export function GoalTeamResults({sessionId, zh, refreshKey}: {sessionId: string;
catch (failure) {if (current === generation.current) setError(`${zh ? "无法核验,已清除上次报告。" : "Cannot verify; previous report cleared."} ${String(failure)}`);}
finally {if (current === generation.current) setBusy(false);}
}
+ function returnToCatalog() {
+ generation.current++;
+ chosen.current = null;
+ focusReport.current = false;
+ setReading(false); setSelection(null); setBusy(false); setError("");
+ focus.row(origin.current);
+ }
const rows = pages.flatMap(readableRows);
+ const observationGeneration = generation.current;
const lastPage = pages.at(-1);
const adoptions = selection?.result.adoptions ?? [];
const currentAdoptions = adoptions.filter(row => row.state === "current").length;
@@ -103,27 +124,32 @@ export function GoalTeamResults({sessionId, zh, refreshKey}: {sessionId: string;
: selection?.result.dependencies?.length
? (zh ? "此结果关联来源版本 · 查看依据" : "This result references source versions · inspect evidence")
: (zh ? "尚无采用记录 · 查看验收与版本依据" : "No adoption recorded · inspect acceptance and versions");
- return
+ return
{zh ? "团队成果" : "Team results"}
{busy ? {zh ? "正在核验产物…" : "Verifying artifacts…"}
: null}
{error ? {error}
: null}
{pages.some(page => !page.page_readback_complete) ? {zh ? "已检查的工作中有无法核验的记录;当前报告只代表它自己的验收,不代表整个团队。" : "Some inspected work cannot be verified. This report reflects only its own acceptance, not the whole team."}
: null}
- {pages.length ?
;
}
function RemainingWork({selectedTodoId, label, children}: {selectedTodoId: string | null; label: string; children: ReactNode}) {
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx
index 178a23eead..89ef9869e3 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx
@@ -705,7 +705,7 @@ const en = {
"header.tasks": "Tasks",
"header.themeBrutal": "Switch to brutal theme",
"header.themePaper": "Switch to default theme",
- "home.blockingSummary": "{count} are blocking an Agent.",
+ "home.blockingSummary": "{count} Goals have work waiting on you.",
"home.completedGoals": "Completed Goals",
"home.empty": "Nothing here",
"startup.goalLoading": "Loading status…",
@@ -746,7 +746,7 @@ const en = {
"home.systemHealth": "System health: {summary}",
"home.taskCount": "{count} Tasks",
"home.todayAt": "Today {time}",
- "home.waitingCount": "You have {count} items to handle.",
+ "home.waitingCount": "{count} Goals need your attention.",
"home.workspace": "Goal workspace",
"lark.allMessages": "All messages in this topic",
"lark.allTopicMessages": "All topic messages",
@@ -1525,8 +1525,17 @@ const en = {
"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",
+ "brief.needs": "Listed attention items",
+ "brief.needsEmpty": "No attention items in the loaded scope",
+ "brief.needsGoals": "Goals needing review",
+ "brief.confirmations": "Operations awaiting your confirmation",
+ "brief.affectedGoals": "Across {count} Goal(s)",
+ "brief.partialGoals": "Partial: some Goals have not loaded",
+ "brief.decision": "Decision",
+ "brief.userAction": "Your task",
+ "brief.runGate": "Run decision",
+ "brief.attention": "Attention item",
+ "brief.collapse": "Show fewer",
"brief.viewAll": "View all {count} items",
"brief.operationDeliveryPending": "Agent: confirmation delivery pending",
"brief.running": "Running now",
@@ -2357,7 +2366,7 @@ const zhCN: Record = {
"header.tasks": "任务",
"header.themeBrutal": "切换到野兽主题",
"header.themePaper": "切换到默认主题",
- "home.blockingSummary": "其中 {count} 项正在阻塞 Agent。",
+ "home.blockingSummary": "其中 {count} 个目标有工作正在等你。",
"home.completedGoals": "已完成的 Goal",
"home.empty": "当前没有",
"startup.goalLoading": "正在加载状态…",
@@ -2398,7 +2407,7 @@ const zhCN: Record = {
"home.systemHealth": "系统健康:{summary}",
"home.taskCount": "{count} 个 Task",
"home.todayAt": "今天 {time}",
- "home.waitingCount": "你有 {count} 项需要处理。",
+ "home.waitingCount": "有 {count} 个目标需要你关注。",
"home.workspace": "Goal 工作区",
"lark.allMessages": "此 Topic 中的所有消息",
"lark.allTopicMessages": "所有 Topic 消息",
@@ -3177,8 +3186,17 @@ const zhCN: Record = {
"timeline.backgroundPaused": "{count} 个已暂停",
"timeline.olderDrafts": "另有 {count} 个待确认提议",
"brief.title": "今日简报",
- "brief.needs": "等你处理",
- "brief.needsEmpty": "没有等你处理的事",
+ "brief.needs": "已列待处理事项",
+ "brief.needsEmpty": "当前已读取范围内没有待处理事项",
+ "brief.needsGoals": "待查看目标",
+ "brief.confirmations": "待你确认的操作",
+ "brief.affectedGoals": "涉及 {count} 个目标",
+ "brief.partialGoals": "部分目标尚未读取,统计不完整",
+ "brief.decision": "需要决定",
+ "brief.userAction": "你的任务",
+ "brief.runGate": "运行决定",
+ "brief.attention": "待处理事项",
+ "brief.collapse": "收起列表",
"brief.viewAll": "查看全部 {count} 项",
"brief.operationDeliveryPending": "Agent 待投递确认卡",
"brief.running": "正在执行",
@@ -3193,7 +3211,7 @@ const zhCN: Record = {
"brief.completed": "最近完成",
"brief.completedEmpty": "还没有完成的工作",
"brief.completedTotal": "累计完成 {count} 项",
- "home.allGoals": "全部 Goal",
+ "home.allGoals": "全部目标",
"home.firstGoal.title": "还没有 Goal",
"home.firstGoal.lead": "一个 Goal 就是你想要的一个结果,Agent 会持续推进它直到你叫停。用你自己的话描述想要的结果,LoopX 管家会先整理成 Goal 草稿,你确认之前不会创建任何东西。",
"home.firstGoal.greeting": "我们从第一个 Goal 开始。",
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx
index fff32cf8bd..5688bfe7d3 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx
@@ -2,7 +2,7 @@ import {AlertTriangle, Check, CircleHelp, Code2, RefreshCw, RotateCcw, ShieldChe
import {ContextHelp} from "./context-help";
import { PerformanceDiagnosisPanel } from "./performance-diagnosis-panel";
import { UsageStatisticsSettings } from "./usage-statistics-settings";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
applyMachineConfiguration,
@@ -125,9 +125,11 @@ export function MachineConfigurationSettings({ section, onChanged }: {
const [previewOperation, setPreviewOperation] = useState<"upsert" | "remove">("upsert");
const [transaction, setTransaction] = useState(null);
const [rollbackPlan, setRollbackPlan] = useState(null);
- const [busy, setBusy] = useState<"load" | "preview" | "apply" | "rollback-preview" | "rollback" | null>("load");
+ const [busy, setBusy] = useState<"load" | "readback" | "preview" | "apply" | "rollback-preview" | "rollback" | null>("load");
const [error, setError] = useState(null);
const [notice, setNotice] = useState(null);
+ const [readbackPending, setReadbackPending] = useState(null);
+ const editorGeneration = useRef(0);
const capabilities = useMemo(() => orderCapabilitiesForPresentation(
(inspection?.capability_catalog.capabilities ?? []).filter((capability) =>
@@ -161,20 +163,37 @@ export function MachineConfigurationSettings({ section, onChanged }: {
: validGuidedDraft(selected, desiredConfiguration ?? {})),
);
- async function reload() {
- setInspection(await fetchMachineConfiguration());
+ async function retryReadback() {
+ if (busy || !readbackPending) return;
+ const generation = editorGeneration.current;
+ setBusy("readback");
+ setError(null);
+ try {
+ const next = await fetchMachineConfiguration();
+ if (generation !== editorGeneration.current) return;
+ setInspection(next);
+ setReadbackPending(null);
+ setNotice(locale === "zh-CN" ? "已重新读取当前配置。" : "Current configuration reloaded.");
+ onChanged?.();
+ } catch (cause) {
+ if (generation === editorGeneration.current) setError(cause instanceof Error ? cause.message : t("machine.loadError"));
+ } finally {
+ if (generation === editorGeneration.current) setBusy(null);
+ }
}
async function retryLoad() {
if (busy) return;
+ const generation = editorGeneration.current;
setBusy("load");
setError(null);
try {
- await reload();
+ const next = await fetchMachineConfiguration();
+ if (generation === editorGeneration.current) setInspection(next);
} catch (cause) {
- setError(cause instanceof Error ? cause.message : t("machine.loadError"));
+ if (generation === editorGeneration.current) setError(cause instanceof Error ? cause.message : t("machine.loadError"));
} finally {
- setBusy(null);
+ if (generation === editorGeneration.current) setBusy(null);
}
}
@@ -194,8 +213,9 @@ export function MachineConfigurationSettings({ section, onChanged }: {
return () => { active = false; };
}, [t]);
- useEffect(() => {
- if (!selected) return;
+ useLayoutEffect(() => {
+ editorGeneration.current++;
+ if (!selected) return () => { editorGeneration.current++; };
const current = currentConfiguration(inspection, selected);
const editable = projectEditableCapabilityConfiguration(
selected.configuration_editor,
@@ -210,9 +230,19 @@ export function MachineConfigurationSettings({ section, onChanged }: {
setPreview(null);
setPreviewOperation("upsert");
setRollbackPlan(null);
- }, [inspection, selectedCapabilityId, locale]);
+ setBusy(null);
+ return () => { editorGeneration.current++; };
+ }, [inspection, selectedCapabilityId, locale, section]);
+
+ useLayoutEffect(() => {
+ // A transaction and its feedback belong to the capability that was edited.
+ setTransaction(null);
+ setError(null);
+ setNotice(null);
+ }, [selected?.capability_id]);
function changeDraft(key: string, value: unknown) {
+ if (busy) return;
setDraft((current) => selected?.capability_id === "periodic_report"
? withReportScheduleTimezone(current, key, value)
: selected?.capability_id === "steward_executor"
@@ -224,7 +254,7 @@ export function MachineConfigurationSettings({ section, onChanged }: {
}
function changeMode(mode: EditorMode) {
- if (!selected) return;
+ if (!selected || busy) return;
if (mode === "json") {
setJsonDraft(JSON.stringify(
completeMachineConfiguration(selected, selectedCurrent, draft),
@@ -246,115 +276,144 @@ export function MachineConfigurationSettings({ section, onChanged }: {
setPreview(null);
setPreviewOperation("upsert");
setError(null);
+ setNotice(null);
}
async function setContextDelivery(mode: "off" | "on") {
if (busy || !editorAvailable || selected?.capability_id !== "context_delivery" || !selected.machine_namespace) return;
const namespace = selected.machine_namespace;
const configuration = {...selected.default, mode};
+ const generation = editorGeneration.current;
setBusy("apply"); setError(null); setNotice(null);
try {
const plan = await previewMachineConfiguration(namespace, configuration);
+ if (generation !== editorGeneration.current) return;
await applyMachineConfiguration(namespace, configuration, plan.plan_revision);
+ if (generation !== editorGeneration.current) { onChanged?.(); return; }
+ setReadbackPending(selected.display_name);
const next = await fetchMachineConfiguration();
+ if (generation !== editorGeneration.current) return;
setInspection(next);
+ setReadbackPending(null);
if (next.machine_configuration?.namespaces[namespace]?.mode !== mode) {
throw new Error(locale === "zh-CN" ? "保存后的配置已变化,请刷新后重试。" : "The saved configuration changed. Refresh and retry.");
}
setNotice(locale === "zh-CN" ? "Codex 偏好已保存。" : "Codex preference saved.");
onChanged?.();
} catch (cause) {
- setError(cause instanceof Error ? cause.message : t("machine.applyError"));
- } finally { setBusy(null); }
+ if (generation === editorGeneration.current) setError(cause instanceof Error ? cause.message : t("machine.applyError"));
+ } finally { if (generation === editorGeneration.current) setBusy(null); }
}
async function createPreview() {
if (!editorAvailable || !selected?.machine_namespace || !desiredConfiguration || !editorValid || busy) return;
+ const generation = editorGeneration.current;
setBusy("preview");
setError(null);
setNotice(null);
try {
setPreviewOperation("upsert");
- setPreview(await previewMachineConfiguration(selected.machine_namespace, desiredConfiguration));
+ const next = await previewMachineConfiguration(selected.machine_namespace, desiredConfiguration);
+ if (generation === editorGeneration.current) setPreview(next);
} catch (cause) {
- setError(cause instanceof Error ? cause.message : t("machine.previewError"));
+ if (generation === editorGeneration.current) setError(cause instanceof Error ? cause.message : t("machine.previewError"));
} finally {
- setBusy(null);
+ if (generation === editorGeneration.current) setBusy(null);
}
}
async function createRemovalPreview() {
if (!editorAvailable || !selected?.machine_namespace || !configured || busy) return;
+ const generation = editorGeneration.current;
setBusy("preview");
setError(null);
setNotice(null);
try {
setPreviewOperation("remove");
- setPreview(await previewMachineConfigurationRemoval(selected.machine_namespace));
+ const next = await previewMachineConfigurationRemoval(selected.machine_namespace);
+ if (generation === editorGeneration.current) setPreview(next);
} catch (cause) {
- setError(cause instanceof Error ? cause.message : t("machine.previewError"));
+ if (generation === editorGeneration.current) setError(cause instanceof Error ? cause.message : t("machine.previewError"));
} finally {
- setBusy(null);
+ if (generation === editorGeneration.current) setBusy(null);
}
}
async function applyPreview() {
if (!editorAvailable || !selected?.machine_namespace || !preview || busy || (previewOperation === "upsert" && !desiredConfiguration)) return;
+ const generation = editorGeneration.current;
setBusy("apply");
setError(null);
+ setNotice(null);
const operation = previewOperation;
try {
const result = operation === "remove"
? await applyMachineConfigurationRemoval(selected.machine_namespace, preview.plan_revision)
: await applyMachineConfiguration(selected.machine_namespace, desiredConfiguration!, preview.plan_revision);
+ if (generation !== editorGeneration.current) { onChanged?.(); return; }
setTransaction(result);
setPreview(null);
setPreviewOperation("upsert");
setRollbackPlan(null);
- await reload();
+ setReadbackPending(selected.display_name);
+ const next = await fetchMachineConfiguration();
+ if (generation !== editorGeneration.current) return;
+ setInspection(next);
+ setReadbackPending(null);
setNotice(result.status === "applied"
? t(operation === "remove" ? "machine.removed" : "machine.applied")
: t("machine.unchanged"));
onChanged?.();
} catch (cause) {
+ if (generation !== editorGeneration.current) return;
setPreview(null);
setPreviewOperation("upsert");
setError(cause instanceof Error ? cause.message : t("machine.applyError"));
} finally {
- setBusy(null);
+ if (generation === editorGeneration.current) setBusy(null);
}
}
async function previewRollback() {
if (!transaction?.transaction_id || busy) return;
+ const generation = editorGeneration.current;
setBusy("rollback-preview");
setError(null);
try {
- setRollbackPlan(await previewMachineConfigurationRollback(transaction.transaction_id));
+ const next = await previewMachineConfigurationRollback(transaction.transaction_id);
+ if (generation === editorGeneration.current) setRollbackPlan(next);
} catch (cause) {
- setError(cause instanceof Error ? cause.message : t("machine.rollbackError"));
+ if (generation === editorGeneration.current) setError(cause instanceof Error ? cause.message : t("machine.rollbackError"));
} finally {
- setBusy(null);
+ if (generation === editorGeneration.current) setBusy(null);
}
}
async function applyRollback() {
if (!transaction?.transaction_id || !rollbackPlan || busy) return;
+ const generation = editorGeneration.current;
setBusy("rollback");
setError(null);
+ setNotice(null);
try {
await applyMachineConfigurationRollback(transaction.transaction_id, rollbackPlan.plan_revision);
+ if (generation !== editorGeneration.current) { onChanged?.(); return; }
setTransaction(null);
setRollbackPlan(null);
setPreview(null);
- await reload();
+ setReadbackPending(selected?.display_name ?? t("settings.scope.machine"));
+ const next = await fetchMachineConfiguration();
+ if (generation !== editorGeneration.current) return;
+ setInspection(next);
+ setReadbackPending(null);
setNotice(t("machine.rolledBack"));
onChanged?.();
} catch (cause) {
+ if (generation !== editorGeneration.current) return;
setRollbackPlan(null);
setError(cause instanceof Error ? cause.message : t("machine.rollbackError"));
} finally {
- setBusy(null);
+ if (generation === editorGeneration.current) setBusy(null);
}
}
@@ -388,6 +447,14 @@ export function MachineConfigurationSettings({ section, onChanged }: {
lives in one body element so the surface keeps exactly two grid rows
however many notices the editor needs. */}
+ {readbackPending ?
+
+ {locale === "zh-CN" ? "配置已写入,读回尚未确认" : "Configuration written; readback unverified"}
+
{readbackPending}
+ void retryReadback()} type="button">
+ {busy === "readback" ? t("common.loading") : t("capabilities.refreshSource")}
+
+ : null}
{inspection?.status === "invalid" ? (
{t("machine.invalidStoredConfiguration")}
@@ -473,7 +540,7 @@ export function MachineConfigurationSettings({ section, onChanged }: {
: "Currently supports only Codex App and benchmark heartbeats; unknown or unsupported declarations return an error. Saving a preference does not establish use in the current session or change other applications or explicitly disabled runs. Delta requires a ready host and complete retained context; otherwise full context is preserved. Private storage and session isolation are automatic; each delivery reports its actual mode."}
: null}
{editorAvailable && selected.capability_id !== "context_delivery" ? <>{editorMode === "json" || !selected.configuration_editor.fields.some((field) => field.key === "enabled" && field.input_kind === "boolean") ?
- changeMode(editorMode === "guided" ? "json" : "guided")} type="button">
+ changeMode(editorMode === "guided" ? "json" : "guided")} type="button">
{t(editorMode === "guided" ? "machine.editJson" : "machine.backToForm")}
: null}
@@ -484,13 +551,13 @@ export function MachineConfigurationSettings({ section, onChanged }: {
advancedKeys={selected.capability_id === "todo_replan_cadence" ? ["planning_horizon_goal"] : []}
omitKeys={selected.capability_id === "steward_executor" && draft.selection_policy !== "flexible" ? ["eligible_endpoints"] : []}
onChange={changeDraft} value={draft}
- enabledAction={
changeMode("json")} type="button">{t("machine.editJson")}} />
+ enabledAction={
changeMode("json")} type="button">{t("machine.editJson")}} />
{!editorValid ?
{t("machine.requiredFields")}
: null}
) : (
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/manager-brief.tsx b/apps/presentation/dashboard/src/features/personal-workspace/manager-brief.tsx
index 9c6a320f97..2390d4a316 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/manager-brief.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/manager-brief.tsx
@@ -1,62 +1,86 @@
import {ContextHelp} from "./context-help";
-import {ArrowRight, Check, CircleHelp} from "lucide-react";
-import type { ReactNode } from "react";
+import {ArrowRight, Check, CircleHelp, Hand} from "lucide-react";
+import {useState, type ReactNode} from "react";
import { useWorkspaceI18n } from "./i18n";
-import type { WorkspaceActionPreview, WorkspaceGoal } from "./personal-workspace-model";
+import type { WorkspaceActionPreview, WorkspaceAttention, WorkspaceGoal } from "./personal-workspace-model";
import { workspaceHomeLaneForGoal } from "./personal-workspace-model";
import { goalWorkKind, presentGoalActivity } from "./goal-activity";
import { GoalIdentityMark, useExecutionDetail } from "./goal-activity-view";
+import { canReviewAttention } from "./attention-details";
import { recentCompletions } from "./recent-completions";
const briefRowLimit = 3;
-type BriefRow = { goal: WorkspaceGoal; key: string; meta?: ReactNode; text: string; proposal?: WorkspaceActionPreview };
+type BriefRow = { goal: WorkspaceGoal; key: string; meta?: ReactNode; text: string; proposal?: WorkspaceActionPreview; attention?: WorkspaceAttention };
function RunningMeta({ goal }: { goal: WorkspaceGoal }) {
return <>{useExecutionDetail(goal.execution).join(" · ") || goal.title}>;
}
-function BriefTile({ count, empty, kind, live = false, onSelectGoal, onSelectOperation, onViewAll, rows, title, total }: {
+function BriefTile({ count, empty, kind, live = false, onSelectGoal, onSelectOperation, onSelectAttention, rows, title, total }: {
count: number;
empty: string;
- kind: "needs" | "operations" | "running" | "claimed" | "completed";
+ kind: "needs" | "needs-goals" | "confirmations" | "operations" | "running" | "claimed" | "completed";
live?: boolean;
onSelectGoal: (goalId: string) => void;
onSelectOperation?: (proposal: WorkspaceActionPreview) => void;
- onViewAll?: () => void;
+ onSelectAttention?: (item: WorkspaceAttention) => void;
rows: BriefRow[];
title: string;
total?: string | null;
}) {
const { t } = useWorkspaceI18n();
+ const [expanded, setExpanded] = useState(false);
return (
{title}{!rows.length ? {empty}
: null}{count}
{rows.length ?
- {rows.slice(0, briefRowLimit).map((row) => (
+ {(expanded ? rows : rows.slice(0, briefRowLimit)).map((row) => (
row.proposal ? onSelectOperation?.(row.proposal) : onSelectGoal(row.goal.goalId)} type="button">
- {kind === "completed" ? : }
+ data-attention-id={row.attention?.todoId} data-attention-source={row.attention?.sourceId}
+ onClick={() => row.proposal ? onSelectOperation?.(row.proposal)
+ : row.attention && onSelectAttention ? onSelectAttention(row.attention) : onSelectGoal(row.goal.goalId)} type="button">
+ {kind === "completed" ?
+ : row.attention ? : }
{row.text}{row.meta ?? row.goal.title}
))}
: null}
- {total ? : null}
- {onViewAll && rows.length > briefRowLimit ? : null}
+ {total || rows.length > briefRowLimit ? : null}
);
}
-export function ManagerBrief({ goals, onSelectGoal, operations = [], onSelectOperation, onViewAllOperations }: {
+export function ManagerBrief({ goals, onSelectGoal, attention = [], onSelectAttention, operations = [], onSelectOperation }: {
goals: WorkspaceGoal[]; onSelectGoal: (goalId: string) => void;
+ attention?: WorkspaceAttention[]; onSelectAttention?: (item: WorkspaceAttention) => void;
operations?: WorkspaceActionPreview[]; onSelectOperation?: (proposal: WorkspaceActionPreview) => void;
onViewAllOperations?: () => void;
}) {
const { t } = useWorkspaceI18n();
const active = goals.filter((goal) => goal.activationState === "active" && !goal.loadState);
const needs = active.filter((goal) => workspaceHomeLaneForGoal(goal) === "needs_you");
+ // Count only observed native items. A Goal digest is a different unit, and
+ // equal titles never prove that an attention item and an operation are one.
+ const seen = new Set
();
+ const attentionRows: BriefRow[] = attention.flatMap(item => {
+ const goal = goals.find(candidate => candidate.goalId === item.goalId && !candidate.loadState);
+ if (!goal || !canReviewAttention(item)) return [];
+ const key = JSON.stringify([item.sourceId ?? "", item.goalId, item.decisionSource ?? "todo", item.todoId]);
+ if (seen.has(key)) return [];
+ seen.add(key);
+ return [{goal, key, attention: item, text: item.text, meta: [goal.title,
+ item.decisionSource === "run_operator_gate" ? t("brief.runGate")
+ : item.details?.interaction === "decision" ? t("brief.decision")
+ : item.details?.interaction === "user_action" ? t("brief.userAction") : t("brief.attention"),
+ ].join(" · ")}];
+ });
+ const summarizedGoals = needs.filter(goal => !attentionRows.some(row => row.goal === goal));
+ const incomplete = goals.some(goal => goal.loadState);
const operationRows: BriefRow[] = operations.flatMap(proposal => {
const goal = goals.find(goal => goal.goalId === proposal.goalId && !goal.loadState);
return goal ? [{goal, key: proposal.previewId, proposal, text: proposal.title,
@@ -79,13 +103,16 @@ export function ManagerBrief({ goals, onSelectGoal, operations = [], onSelectOpe
: executionPending ? t("brief.runningReading") : t("activity.executionUnknown");
return (
- briefRowLimit ? onViewAllOperations : undefined}
- rows={[...deliveredOperations, ...needs.map((goal) => ({ goal, key: goal.goalId, meta: goal.title, text: goal.needsYou ?? goal.nextSentence }))]}
- title={t("brief.needs")} />
+ row.goal.goalId)).size}) : null} />
+ {summarizedGoals.length ? ({goal, key: goal.goalId, meta: goal.title, text: goal.needsYou ?? goal.nextSentence}))}
+ title={t("brief.needsGoals")} /> : null}
+ {deliveredOperations.length ? : null}
{awaitingDelivery.length ? : null}
presentGoalActivity(goal).live)} onSelectGoal={onSelectGoal}
rows={running.map((goal) => ({ goal, key: goal.goalId, meta: , text: goal.title }))}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/markdown-blocks.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/markdown-blocks.test.mjs
new file mode 100644
index 0000000000..d51752eaa2
--- /dev/null
+++ b/apps/presentation/dashboard/src/features/personal-workspace/markdown-blocks.test.mjs
@@ -0,0 +1,43 @@
+import assert from "node:assert/strict";
+import {test} from "node:test";
+import {markdownExcerpt, markdownOutline, parseMarkdownBlocks} from "./markdown-blocks.ts";
+
+test("document anchors are stable and unique across repeated headings and suffix collisions", () => {
+ const text = "# 阅读\n\n## 阅读\n\n## 阅读-2\n\n### Evidence\n\n###### Detail";
+ const outline = markdownOutline(parseMarkdownBlocks(text), "answer");
+ assert.deepEqual(outline.map(row => row.id), ["answer-阅读", "answer-阅读-2", "answer-阅读-2-2", "answer-evidence", "answer-detail"]);
+ assert.deepEqual(outline.map(row => row.level), [2, 3, 3, 4, 6]);
+ assert.deepEqual(markdownOutline(parseMarkdownBlocks(text), "answer"), outline, "Reload keeps the same fragment destinations");
+ assert.deepEqual(markdownOutline(parseMarkdownBlocks("#### Start\n\n###### Detail"), "second").map(row => row.level), [2, 4]);
+});
+
+test("an excerpt is a verbatim prefix ending outside code, list and table blocks", () => {
+ const code = ["````text", ...Array.from({length: 200}, (_, n) => `line ${n}: exact source`), "```", "still inside the four-backtick fence", "````"].join("\n");
+ const suffix = `\n\n## Next\n\n${"后续记录。".repeat(600)}`;
+ const text = code + suffix;
+ assert.equal(markdownExcerpt(text), code, "The full 200-line fence remains intact even beyond the preview budget");
+ const parsed = parseMarkdownBlocks(text);
+ assert.equal(parsed[0].type, "code");
+ assert.ok(parsed[0].text.endsWith("```\nstill inside the four-backtick fence"));
+
+ for (const block of [
+ Array.from({length: 90}, (_, n) => `- requirement ${n}: keep the complete list`).join("\n"),
+ ["| Measure | Value |", "|---|---|", ...Array.from({length: 90}, (_, n) => `| item ${n} | exact value |`)].join("\n"),
+ ]) {
+ assert.equal(markdownExcerpt(block + suffix), block);
+ }
+});
+
+test("single long blocks and short answers stay complete; heading-only prefixes are not excerpts", () => {
+ assert.equal(markdownExcerpt("短答复。"), null);
+ assert.equal(markdownExcerpt("unbroken answer ".repeat(500)), null);
+ const introduction = "首段完整陈述。".repeat(200);
+ const text = `# 原标题\n\n${introduction}\n\n## 后续\n\n${"更多完整正文。".repeat(300)}`;
+ assert.equal(markdownExcerpt(text), `# 原标题\n\n${introduction}`);
+});
+
+test("unclosed and tilde fences retain their literal content", () => {
+ const text = "~~~text\n# Not a heading\n| Not | a table |\n~~~\n\n## Actual heading";
+ assert.deepEqual(markdownOutline(parseMarkdownBlocks(text), "answer").map(row => row.text), ["Actual heading"]);
+ assert.equal(markdownExcerpt("```\n" + "code\n".repeat(1000)), null, "An unclosed block cannot be advertised as a complete excerpt");
+});
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/markdown-blocks.ts b/apps/presentation/dashboard/src/features/personal-workspace/markdown-blocks.ts
new file mode 100644
index 0000000000..20e9ebeeda
--- /dev/null
+++ b/apps/presentation/dashboard/src/features/personal-workspace/markdown-blocks.ts
@@ -0,0 +1,124 @@
+/** One parser owns rendering, heading navigation and verbatim history excerpts. */
+export type MarkdownBlock = {startLine: number; endLine: number} & (
+ | {type: "code"; text: string}
+ | {type: "table"; headers: string[]; rows: string[][]}
+ | {type: "heading"; level: number; text: string}
+ | {type: "list"; ordered: boolean; items: string[]}
+ | {type: "paragraph"; lines: string[]}
+);
+type BlockContent = MarkdownBlock extends infer T ? T extends MarkdownBlock ? Omit : never : never;
+
+const UNORDERED = /^\s*[-*•]\s+(.*)$/;
+const ORDERED = /^\s*\d{1,9}[.、)]\s+(.*)$/;
+
+function tableCells(line: string) {
+ return line.trim().replace(/^\|/, "").replace(/(? cell.trim().replace(/\\\|/g, "|"));
+}
+
+export function parseMarkdownBlocks(text: string): MarkdownBlock[] {
+ const lines = text.split("\n");
+ const blocks: MarkdownBlock[] = [];
+ let paragraph: string[] = [], paragraphStart = 0;
+ const push = (block: BlockContent, startLine: number, endLine: number) => {
+ blocks.push({...block, startLine, endLine});
+ };
+ const flushParagraph = () => {
+ if (!paragraph.length) return;
+ push({type: "paragraph", lines: paragraph}, paragraphStart, paragraphStart + paragraph.length);
+ paragraph = [];
+ };
+
+ let i = 0;
+ while (i < lines.length) {
+ const line = lines[i];
+ const fence = line.match(/^\s{0,3}(`{3,}|~{3,})/u);
+ if (fence) {
+ flushParagraph();
+ const start = i++, buffer: string[] = [];
+ while (i < lines.length) {
+ const close = lines[i].match(/^\s{0,3}(`+|~+)\s*$/u);
+ if (close && close[1][0] === fence[1][0] && close[1].length >= fence[1].length) break;
+ buffer.push(lines[i++]);
+ }
+ if (i < lines.length) i += 1;
+ push({type: "code", text: buffer.join("\n")}, start, i);
+ continue;
+ }
+ const heading = line.match(/^\s{0,3}(#{1,6})\s+(.*)$/u);
+ if (heading) {
+ flushParagraph();
+ push({type: "heading", level: heading[1].length, text: heading[2].trim().replace(/\s+#+\s*$/u, "")}, i, i + 1);
+ i += 1;
+ continue;
+ }
+ if (line.includes("|") && i + 1 < lines.length) {
+ const headers = tableCells(line), separators = tableCells(lines[i + 1]);
+ if (headers.length === separators.length && separators.every(cell => /^:?-{3,}:?$/.test(cell))) {
+ flushParagraph();
+ const start = i, rows: string[][] = [];
+ i += 2;
+ while (i < lines.length && lines[i].includes("|")) {
+ const cells = tableCells(lines[i]);
+ if (cells.length !== headers.length) break;
+ rows.push(cells); i += 1;
+ }
+ push({type: "table", headers, rows}, start, i);
+ continue;
+ }
+ }
+ if (UNORDERED.test(line) || ORDERED.test(line)) {
+ flushParagraph();
+ const start = i, ordered = ORDERED.test(line), items: string[] = [];
+ const pattern = ordered ? ORDERED : UNORDERED;
+ while (i < lines.length) {
+ const item = lines[i].match(pattern);
+ if (!item) break;
+ items.push(item[1]); i += 1;
+ }
+ push({type: "list", ordered, items}, start, i);
+ continue;
+ }
+ if (line.trim() === "") {
+ flushParagraph(); i += 1;
+ continue;
+ }
+ if (!paragraph.length) paragraphStart = i;
+ paragraph.push(line); i += 1;
+ }
+ flushParagraph();
+ return blocks;
+}
+
+export type MarkdownHeading = {blockIndex: number; id: string; level: number; text: string};
+
+/** Stable per-document anchors, including duplicate text and suffix collisions. */
+export function markdownOutline(blocks: MarkdownBlock[], prefix: string, firstLevel = 2): MarkdownHeading[] {
+ const headings = blocks.flatMap((block, blockIndex) => block.type === "heading" ? [{...block, blockIndex}] : []);
+ const first = headings.reduce((level, heading) => Math.min(level, heading.level), 6);
+ const used = new Set();
+ return headings.map(heading => {
+ const slug = [...heading.text.normalize("NFKC").toLowerCase().replace(/[`*]/gu, "")
+ .replace(/[^\p{L}\p{N}_-]+/gu, "-").replace(/^-+|-+$/gu, "")].slice(0, 80).join("") || "section";
+ const base = `${prefix}-${slug}`;
+ let id = base, occurrence = 1;
+ while (used.has(id)) id = `${base}-${++occurrence}`;
+ used.add(id);
+ return {blockIndex: heading.blockIndex, id, text: heading.text,
+ level: Math.min(6, Math.max(1, firstLevel + heading.level - first))};
+ });
+}
+
+/** A prefix of complete original blocks; no clipped sentence, code, table or list. */
+export function markdownExcerpt(text: string): string | null {
+ if ([...text].length < 2400) return null;
+ const blocks = parseMarkdownBlocks(text), lines = text.split("\n");
+ let contentBlocks = 0;
+ for (const [index, block] of blocks.entries()) {
+ if (block.type === "heading") continue;
+ contentBlocks += 1;
+ const excerpt = lines.slice(0, block.endLine).join("\n");
+ if (contentBlocks < 3 && [...excerpt].length < 1000) continue;
+ return index < blocks.length - 1 ? excerpt : null;
+ }
+ return null;
+}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/markdown.css b/apps/presentation/dashboard/src/features/personal-workspace/markdown.css
new file mode 100644
index 0000000000..47435218b8
--- /dev/null
+++ b/apps/presentation/dashboard/src/features/personal-workspace/markdown.css
@@ -0,0 +1,9 @@
+.personal-md { min-width: 0; }
+.personal-md .personal-md-heading { margin: 0; scroll-margin-top: 24px; }
+.personal-md .personal-md-heading.is-h5,
+.personal-md .personal-md-heading.is-h6 { font-size: var(--text-body); line-height: var(--leading-body); }
+.personal-md .personal-md-pre,
+.personal-md .personal-md-table-scroll { min-width: 0; max-width: 100%; overscroll-behavior-x: contain; }
+.personal-md .personal-md-pre:focus-visible,
+.personal-md .personal-md-table-scroll:focus-visible,
+.personal-md .personal-md-heading:focus-visible { outline: 2px solid var(--pw-blue-ink, currentColor); outline-offset: 3px; }
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/markdown.tsx b/apps/presentation/dashboard/src/features/personal-workspace/markdown.tsx
index ebb511c8db..5374cd08c3 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/markdown.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/markdown.tsx
@@ -1,5 +1,7 @@
-import { Fragment, type ReactNode } from "react";
+import { Fragment, useId, useMemo, type ReactNode } from "react";
import { LinkifyIt } from "linkify-it";
+import {markdownOutline, parseMarkdownBlocks} from "./markdown-blocks";
+import "./markdown.css";
/**
* Minimal, safe Markdown renderer for visible Agent prose.
@@ -114,105 +116,26 @@ function renderInline(text: string, keyPrefix: string): ReactNode[] {
return nodes;
}
-type Block =
- | { type: "code"; text: string }
- | { type: "table"; headers: string[]; rows: string[][] }
- | { type: "heading"; level: number; text: string }
- | { type: "list"; ordered: boolean; items: string[] }
- | { type: "paragraph"; lines: string[] };
-
-const UNORDERED = /^\s*[-*•]\s+(.*)$/;
-const ORDERED = /^\s*\d{1,2}[.、)]\s+(.*)$/;
-
-function tableCells(line: string) {
- return line.trim().replace(/^\|/, "").replace(/(? cell.trim().replace(/\\\|/g, "|"));
-}
-
-function parseBlocks(text: string): Block[] {
- const lines = text.split("\n");
- const blocks: Block[] = [];
- let paragraph: string[] = [];
- const flushParagraph = () => {
- if (paragraph.length > 0) {
- blocks.push({ type: "paragraph", lines: paragraph });
- paragraph = [];
- }
- };
-
- let i = 0;
- while (i < lines.length) {
- const line = lines[i];
- if (/^\s*```/.test(line)) {
- flushParagraph();
- const buffer: string[] = [];
- i += 1;
- while (i < lines.length && !/^\s*```/.test(lines[i])) {
- buffer.push(lines[i]);
- i += 1;
- }
- i += 1; // closing fence, or end of text
- blocks.push({ type: "code", text: buffer.join("\n") });
- continue;
- }
- const heading = line.match(/^\s{0,3}#{1,4}\s+(.*)$/);
- if (heading) {
- flushParagraph();
- const marks = line.trimStart().match(/^#+/)?.[0].length ?? 1;
- blocks.push({ type: "heading", level: marks, text: heading[1].trim() });
- i += 1;
- continue;
- }
- if (line.includes("|") && i + 1 < lines.length) {
- const headers = tableCells(line), separators = tableCells(lines[i + 1]);
- if (headers.length === separators.length && separators.every(cell => /^:?-{3,}:?$/.test(cell))) {
- flushParagraph();
- const rows: string[][] = [];
- i += 2;
- while (i < lines.length && lines[i].includes("|")) {
- const cells = tableCells(lines[i]);
- if (cells.length !== headers.length) break;
- rows.push(cells); i++;
- }
- blocks.push({type: "table", headers, rows});
- continue;
- }
- }
- if (UNORDERED.test(line) || ORDERED.test(line)) {
- flushParagraph();
- const ordered = ORDERED.test(line);
- const pattern = ordered ? ORDERED : UNORDERED;
- const items: string[] = [];
- while (i < lines.length) {
- const item = lines[i].match(pattern);
- if (!item) break;
- items.push(item[1]);
- i += 1;
- }
- blocks.push({ type: "list", ordered, items });
- continue;
- }
- if (line.trim() === "") {
- flushParagraph();
- i += 1;
- continue;
- }
- paragraph.push(line);
- i += 1;
- }
- flushParagraph();
- return blocks;
-}
-
-export function MarkdownText({ text }: { text: string }) {
+export function MarkdownText({text, headingPrefix, headingLevel = 2}: {
+ text: string;
+ headingPrefix?: string;
+ headingLevel?: 1 | 2 | 3 | 4 | 5 | 6;
+}) {
+ const instanceId = useId();
+ const blocks = useMemo(() => parseMarkdownBlocks(text), [text]);
+ const headings = markdownOutline(blocks, headingPrefix ?? `md-${instanceId}`, headingLevel);
+ const headingsByBlock = new Map(headings.map(heading => [heading.blockIndex, heading]));
return (
- {parseBlocks(text).map((block, index) => {
+ {blocks.map((block, index) => {
const key = `b${index}`;
if (block.type === "code") {
- return
{block.text}
;
+ return
{block.text}
;
}
if (block.type === "heading") {
- return
{renderInline(block.text, key)}
;
+ const heading = headingsByBlock.get(index)!;
+ const Heading = `h${heading.level}` as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
+ return
{renderInline(block.text, key)};
}
if (block.type === "table") {
return
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/message-reading.css b/apps/presentation/dashboard/src/features/personal-workspace/message-reading.css
new file mode 100644
index 0000000000..cf9605e93c
--- /dev/null
+++ b/apps/presentation/dashboard/src/features/personal-workspace/message-reading.css
@@ -0,0 +1,9 @@
+.personal-message-reading { min-width: 0; }
+.personal-message-reading-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin-block: 4px 8px; }
+.personal-message-reading-controls button { min-height: 44px; padding: 8px 0; color: var(--pw-blue-ink); background: transparent; border: 0; cursor: pointer; font: inherit; }
+.personal-message-reading-controls button:hover { text-decoration: underline; text-underline-offset: 3px; }
+.personal-message-reading-controls span { color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); }
+.personal-message[tabindex="-1"] { scroll-margin-block: 24px; }
+.personal-message[tabindex="-1"]:focus-visible { outline: 2px solid var(--pw-blue-ink); outline-offset: 4px; }
+.personal-message-focus-notice { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin-block: 8px 16px; color: var(--pw-muted); }
+.personal-message-focus-notice button { min-height: 44px; padding: 8px 12px; border: 1px solid var(--pw-line); border-radius: 6px; color: var(--pw-text); background: var(--pw-card); cursor: pointer; }
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/message-reading.tsx b/apps/presentation/dashboard/src/features/personal-workspace/message-reading.tsx
new file mode 100644
index 0000000000..31807b56f1
--- /dev/null
+++ b/apps/presentation/dashboard/src/features/personal-workspace/message-reading.tsx
@@ -0,0 +1,40 @@
+import {useEffect, useId, useLayoutEffect, useMemo, useRef, useState} from "react";
+import {MarkdownText} from "./markdown";
+import {markdownExcerpt} from "./markdown-blocks";
+import "./message-reading.css";
+
+/** Folding is an explicit reading choice after the initial historical snapshot. */
+export function HistoricalMessageText({text, initiallyCollapsed, focused, zh, onRead}: {
+ text: string;
+ initiallyCollapsed: boolean;
+ focused: boolean;
+ zh: boolean;
+ onRead?: () => void;
+}) {
+ const id = useId();
+ const excerpt = useMemo(() => initiallyCollapsed ? markdownExcerpt(text) : null, [text, initiallyCollapsed]);
+ const [expanded, setExpanded] = useState(!initiallyCollapsed || focused);
+ const toggle = useRef(null);
+ const anchor = useRef(null);
+ useEffect(() => {if (focused) setExpanded(true);}, [focused]);
+ useLayoutEffect(() => {
+ if (anchor.current === null || !toggle.current) return;
+ const scroller = toggle.current.closest(".personal-channel-scroll");
+ const delta = toggle.current.getBoundingClientRect().top - anchor.current;
+ if (scroller) scroller.scrollTop += delta;
+ toggle.current.focus({preventScroll: true});
+ anchor.current = null;
+ }, [expanded]);
+ if (excerpt === null) return ;
+ return
+
+ {
+ onRead?.();
+ anchor.current = toggle.current?.getBoundingClientRect().top ?? null;
+ setExpanded(value => !value);
+ }}>{expanded ? (zh ? "收起消息" : "Collapse message") : (zh ? "展开完整消息" : "Read full message")}
+ {!expanded ? {zh ? "原文节选" : "Original excerpt"} : null}
+
+
+
;
+}
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 bdec991bfa..2cfac5bcbe 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
@@ -4,7 +4,7 @@ import {readSettingsRoute, writeSettingsRoute} from "./workspace-settings-route"
import {hasConfiguredGoalChecks} from "../../data/goal-acceptance-observation";
import {useDeliveryReview} from "./delivery-review-state";
import {useGoalWorkspaceDigest} from "./goal-masthead";
-import { goalCreateRequest, goalCreationAgentId } from "./goal-create-request";
+import { goalCreateRequest, goalCreationAgentId, goalDraftReady } from "./goal-create-request";
import { todoCompletionPreviewFeedback, todoCompletionProofRequired } from "./todo-completion-feedback";
import { readSteeringRequest, retainSteeringRequest, retireSteeringRequest, legacySteeringRequests, restoreLegacySteeringRequest } from "./steering-recovery";
import { conversationInputKey, useConversationInputState } from "./use-conversation-input-state";
@@ -64,6 +64,7 @@ import { GoalResearchWorkbench } from "./goal-research-workbench";
import { GoalOverview, goalOverviewAttentionId } from "./goal-overview";
import { GoalVitals, GoalVitalsSkeleton, type GoalVitalRegion } from "./goal-masthead";
import { GoalWorkspacePanels } from "./goal-workspace-panels";
+import {GoalWorkBrowseProvider, readGoalWorkBrowse, type GoalWorkBrowseState} from "./goal-work-browse";
import { localizedGoalState, localizedSessionStatus, useWorkspaceI18n, type WorkspaceTranslate } from "./i18n";
import { MarkdownText } from "./markdown";
import { ReturnDeliveryStatus } from "./return-delivery-status";
@@ -171,6 +172,8 @@ function RuntimeReadinessNotice({ readiness, rechecking, onRecheck }: { readines
function ManagerHomeBoard({
goals,
+ attention,
+ onSelectAttention,
onSelectGoal,
onRetry,
onRetrySource,
@@ -186,6 +189,8 @@ function ManagerHomeBoard({
onViewAllOperations,
}: {
goals: WorkspaceGoal[];
+ attention: WorkspaceAttention[];
+ onSelectAttention: (item: WorkspaceAttention) => void;
onSelectGoal: (goalId: string) => void;
onRetry?: () => void;
/** Re-read the whole source after its latest read failed. */
@@ -289,7 +294,8 @@ function ManagerHomeBoard({
{t("startup.retryFailed")} : null}
{currentGoals.filter((goal) => goal.loadState).map(goalCard)}
: null}
- {currentGoals.some((goal) => !goal.loadState) ? !goal.loadState) ? : null}
{onboarding?.kind === "no_goals" && goals.length === 0 ? null : {t("home.allGoals")}
}
@@ -379,14 +385,14 @@ function GoalOutputsView({
].filter(Boolean).join(" · ")}
))}
- {localResults && !production ?
: null}
+ {localResults && !production ?
: null}
: null}
{production}
- {production && localResults ?
: null}
- {active && researchApi ?
: null}
+ {production && localResults ?
: null}
+ {active && researchApi ?
: null}
{active && teamSessionId && !teamSnapshot && !teamError ?
{t("files.checkingTeam")}
: null}
{active && teamSessionId && teamError ?
{t("files.teamLoadFailed")} setTeamRefresh(value => value + 1)}>{t("startup.retry")}
: null}
- {active && teamConfigured && teamSessionId ?
: null}
+ {active && teamConfigured && teamSessionId ?
: null}
>
);
}
@@ -923,6 +929,9 @@ export function PersonalWorkspacePage({
conversationSupportsSteering = false,
conversationSessionId,
conversationHistoryState,
+ conversationMessageFocus,
+ workBrowseSearch,
+ onWorkBrowseChange,
agents = [{ agentId: "codex", available: true, capability: "代码与项目执行", label: "Codex" }],
callbacks = {},
goalArchiveLoadState = { error: null, phase: "ready" },
@@ -956,6 +965,9 @@ export function PersonalWorkspacePage({
conversationSupportsSteering?: boolean;
conversationSessionId?: string;
conversationHistoryState?: ConversationHistoryStatus;
+ conversationMessageFocus?: {sessionId: string; messageId: string};
+ workBrowseSearch?: Record
;
+ onWorkBrowseChange?: (patch: Partial, source: string, replace?: boolean) => void;
agents?: WorkspaceAgentOption[];
callbacks?: PersonalWorkspaceCallbacks;
goalArchiveLoadState?: WorkspaceGoalArchiveLoadState;
@@ -998,8 +1010,10 @@ export function PersonalWorkspacePage({
currentSelectionScope.current = {scope: selectionScope, generation: currentSelectionScope.current.generation + 1};
}
const selectionGeneration = currentSelectionScope.current.generation;
+ const selectionRevision = useRef(0);
function setSelection(value: WorkspaceDrawerSelection | null) {
if (currentSelectionScope.current.generation !== selectionGeneration) return;
+ selectionRevision.current += 1;
setSelectionState({scope: selectionScope, value});
const todoId = value?.kind === "todo" ? value.item.todoId : null;
if (todoId !== (routedTodoId ?? null)) callbacks.onSelectTodo?.(todoId);
@@ -1149,7 +1163,7 @@ export function PersonalWorkspacePage({
}, [composer, selectedGoalId, managerChatOpen]);
async function reviewGoalDraft(draft: GoalDraft, edit = false, draftId = "") {
// Source message + reviewed contents survive retry without merging distinct requests.
- if (!edit && !draft.question && draft.completion_criteria.trim()) {
+ if (!edit && goalDraftReady(draft)) {
const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(
JSON.stringify([draftId, draft, goalAgentId, locale])));
const operationId = Array.from(new Uint8Array(bytes), value => value.toString(16).padStart(2, "0")).join("");
@@ -1213,6 +1227,16 @@ export function PersonalWorkspacePage({
[workspaceGoals],
);
const selectedGoal = workspaceGoals.find((goal) => goal.goalId === selectedGoalId) ?? null;
+ const workBrowseSource = researchReadSourceUrl ?? statusSourceControl?.activeSource.statusUrl ?? "/status.json";
+ const workBrowseKey = JSON.stringify([workBrowseSource, selectedGoalId]);
+ const routedWorkBrowse = readGoalWorkBrowse(workBrowseSearch ?? {}, selectedGoalId ?? "", workBrowseSource);
+ const [localWorkBrowse, setLocalWorkBrowse] = useState({scope: workBrowseKey, value: routedWorkBrowse});
+ const workBrowse = onWorkBrowseChange ? routedWorkBrowse
+ : localWorkBrowse.scope === workBrowseKey ? localWorkBrowse.value : routedWorkBrowse;
+ const changeWorkBrowse = (patch: Partial, replace?: boolean) => {
+ if (onWorkBrowseChange) onWorkBrowseChange(patch, workBrowseSource, replace);
+ else setLocalWorkBrowse({scope: workBrowseKey, value: {...workBrowse, ...patch}});
+ };
const rootUnavailable = Boolean(rootGoalId && !selectedGoal);
function openSettings(target: Extract, returnToModel = false) {
settingsReturnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
@@ -1224,6 +1248,7 @@ export function PersonalWorkspacePage({
useEffect(() => {
const restore = () => {
const target = readSettingsRoute(new URL(window.location.href));
+ selectionRevision.current += 1;
// Task history has its own route reader. Leaving settings may clear its
// selection, but an unrelated history event must not clear a restored task.
setSelectionState(current => target ? {scope: currentSelectionScope.current.scope, value: target}
@@ -1398,6 +1423,12 @@ export function PersonalWorkspacePage({
[items, managerSessionProposalIds, managerChannelProposalIds],
);
const conversationOpen = (!agentWorkActive || managedWorkActive) && (selectedGoal ? selectedGoalTab === "chat" : managerChatOpen);
+ const timelineReading = {
+ messageFocus: conversationMessageFocus,
+ historyPhase: conversationHistoryState?.phase,
+ onRetryHistory: conversationHistoryState?.retry,
+ onReadMessage: () => { followConversationRef.current = false; },
+ };
const conversationMessages = selectedGoal ? goalMessages : managerMessages;
const latestMessage = conversationMessages.at(-1);
const latestMessageTextLength = latestMessage?.text.length ?? 0;
@@ -1411,10 +1442,10 @@ export function PersonalWorkspacePage({
}
}
useEffect(() => {
- followConversationRef.current = true;
+ followConversationRef.current = !conversationMessageFocus;
conversationScrollTopRef.current = channelScrollRef.current?.scrollTop ?? 0;
setShowLatestMessage(false);
- }, [conversationKey, selectedAgentId, conversationOpen]);
+ }, [conversationKey, selectedAgentId, conversationOpen, conversationMessageFocus?.sessionId, conversationMessageFocus?.messageId]);
useEffect(() => {
if (!conversationOpen) return;
let frame = 0;
@@ -1449,6 +1480,7 @@ export function PersonalWorkspacePage({
const route = `${selectionScope}:${routedTodoId ?? ""}`;
if (restoredTodoRoute.current === route || !selectedGoal || selectedGoal.loadState) return;
restoredTodoRoute.current = route;
+ selectionRevision.current += 1;
if (!routedTodoId) {
setSelectionState(current => current?.value?.kind === "todo" ? null : current);
return;
@@ -1619,6 +1651,10 @@ export function PersonalWorkspacePage({
);
return currentRun ? { item: currentRun.run, kind: "run" } : selection;
}, [items, selection, proposals, workspaceGoals, model.attentionHistory, model.userTodos, readOnly]);
+ // Home can inspect a linked Goal's request without selecting that Goal first.
+ // Preserve the request owner's source boundary in the shared drawer.
+ const drawerReadOnly = readOnly || workspaceGoals.some(goal => goal.goalId === drawerSelection?.item.goalId
+ && goal.sourceAccess?.readOnly === true);
const creationProposalId = drawerSelection?.kind === "proposal"
&& drawerSelection.item.actionKind === "goal.create"
@@ -1744,7 +1780,9 @@ export function PersonalWorkspacePage({
// The card belongs to the conversation on screen when the request started
// (this render's selectedGoalId), even if its answer lands after the owner
// moved elsewhere.
- if (readOnly) throw new Error(t("source.readOnlyWriteError"));
+ if (readOnly || workspaceGoals.some(goal => goal.goalId === request.normalizedParameters.goal_id
+ && goal.sourceAccess?.readOnly === true)) throw new Error(t("source.readOnlyWriteError"));
+ const originatingSelection = selectionRevision.current;
let local: WorkspaceActionPreview;
try {
local = callbacks.onPreviewAction
@@ -1808,7 +1846,9 @@ export function PersonalWorkspacePage({
}
rememberSessionProposal(local.previewId, selectedGoalId);
setProposals((current) => ({ ...current, [local.previewId]: local }));
- if (options.select !== false && isCurrentConversation()) setSelection({ item: local, kind: "proposal" });
+ if (options.select !== false && isCurrentConversation() && selectionRevision.current === originatingSelection) {
+ setSelection({ item: local, kind: "proposal" });
+ }
return local;
}
@@ -2017,6 +2057,14 @@ export function PersonalWorkspacePage({
const applying = { ...proposal, reviewPlan: proposal.reviewPlan ? { ...proposal.reviewPlan, interaction: "pending" as const, reason: "apply_pending" as const, canApply: false as const } : undefined, status: "applying" as const };
setProposals((current) => ({ ...current, [proposal.previewId]: applying }));
if (showDrawer) setSelection({ item: applying, kind: "proposal" });
+ let applyingSelection = selectionRevision.current;
+ const stillSelected = () => selectionRevision.current === applyingSelection
+ && currentSelectionScope.current.generation === selectionGeneration;
+ const selectReceipt = (item: WorkspaceActionPreview) => {
+ if (!stillSelected()) return;
+ setSelection({item, kind: "proposal"});
+ applyingSelection = selectionRevision.current;
+ };
if (lifecycleChange && !lifecycleChange.optimisticApplied) {
callbacks.onGoalActivationStateChange?.(lifecycleChange.goalId, lifecycleChange.next);
}
@@ -2025,11 +2073,11 @@ export function PersonalWorkspacePage({
await callbacks.onApplyProposal(proposal);
const applied = { ...proposal, status: "applied" as const };
setProposals((current) => ({ ...current, [proposal.previewId]: applied }));
- if (showDrawer) setSelection({ item: applied, kind: "proposal" });
+ if (showDrawer) selectReceipt(applied);
setActionFeedback(t("feedback.completed", { title: proposal.title }));
if (proposal.actionKind === "goal.lifecycle") {
if (proposal.lifecycleOperation === "delete" || (proposal.lifecycleOperation === "stop" && !options.keepGoalSelected)) {
- selectGoal(null);
+ if (stillSelected()) selectGoal(null);
}
if (proposal.lifecycleOperation === "delete" && proposal.goalId) {
callbacks.onGoalDeleted?.(proposal.goalId);
@@ -2054,12 +2102,12 @@ export function PersonalWorkspacePage({
await actionReadback.acceptProposal(result.proposal);
const applied = workspaceProposal(result.proposal, t);
setProposals((current) => ({ ...current, [proposal.previewId]: applied }));
- if (showDrawer) setSelection({ item: applied, kind: "proposal" });
+ if (showDrawer) selectReceipt(applied);
if (applied.reviewPlan?.interaction !== "completed") {
if (lifecycleChange) {
callbacks.onGoalActivationStateChange?.(lifecycleChange.goalId, lifecycleChange.previous);
}
- if (options.presentation !== "inline") setSelection({ item: applied, kind: "proposal" });
+ if (options.presentation !== "inline") selectReceipt(applied);
setActionFeedback(
result.proposal.status === "stale"
? t("feedback.stale")
@@ -2076,7 +2124,7 @@ export function PersonalWorkspacePage({
await callbacks.onRefresh?.();
}
if (applied.actionKind === "goal.lifecycle" && (applied.lifecycleOperation === "delete" || (applied.lifecycleOperation === "stop" && !options.keepGoalSelected))) {
- selectGoal(null);
+ if (stillSelected()) selectGoal(null);
}
if (applied.actionKind === "goal.lifecycle" && applied.lifecycleOperation === "delete" && applied.goalId) {
callbacks.onGoalDeleted?.(applied.goalId);
@@ -2109,13 +2157,13 @@ export function PersonalWorkspacePage({
status: "gated" as const,
};
setProposals((current) => ({ ...current, [proposal.previewId]: gated }));
- // A newly discovered authority gate always deserves review, including
- // when the action started on the direct path.
- if (options.presentation !== "inline") setSelection({ item: gated, kind: "proposal" });
+ // A newly discovered authority gate always deserves review. Preserve
+ // its receipt and feedback without replacing a newer selection.
+ if (options.presentation !== "inline") selectReceipt(gated);
setActionFeedback(t("feedback.gateRequired", { summary: gated.gate.summary }));
if (proposal.actionKind === "goal.create" && proposal.goalId) {
callbacks.onRefresh?.();
- selectGoal(proposal.goalId);
+ if (stillSelected()) selectGoal(proposal.goalId);
}
return gated;
}
@@ -2139,7 +2187,7 @@ export function PersonalWorkspacePage({
const reconciled = { ...observed, errorMessage: observed.status === "error"
? error instanceof Error ? error.message : String(error) : undefined };
setProposals(current => ({ ...current, [proposal.previewId]: reconciled }));
- if (options.presentation !== "inline") setSelection({ item: reconciled, kind: "proposal" });
+ if (options.presentation !== "inline") selectReceipt(reconciled);
if (observed.reviewPlan?.creationProgress?.goalId) void callbacks.onRefresh?.();
setActionFeedback(stored.status === "stale" ? t("feedback.stale")
: observed.reviewPlan?.interaction === "completed" ? t("feedback.completed", { title: observed.title })
@@ -2155,7 +2203,7 @@ export function PersonalWorkspacePage({
status: (stale ? "stale" : "error") as "stale" | "error",
};
setProposals((current) => ({ ...current, [proposal.previewId]: failed }));
- if (options.presentation !== "inline") setSelection({ item: failed, kind: "proposal" });
+ if (options.presentation !== "inline") selectReceipt(failed);
setActionFeedback(t("feedback.executionFailed", { error: failed.errorMessage }));
return failed;
}
@@ -2306,7 +2354,7 @@ export function PersonalWorkspacePage({
});
},
};
- const effectiveDrawerCallbacks: PersonalWorkspaceCallbacks = readOnly ? {
+ const effectiveDrawerCallbacks: PersonalWorkspaceCallbacks = drawerReadOnly ? {
onOpenGoal: drawerCallbacks.onOpenGoal,
onOpenGoalView: drawerCallbacks.onOpenGoalView,
onOpenOutput: drawerCallbacks.onOpenOutput,
@@ -2773,7 +2821,7 @@ export function PersonalWorkspacePage({
else setSelection(entry.destination.selection);
}} />}
notice={serviceNotice}
- drawer={drawerSelection ? setSelection({ kind: "attention", item })} callbacks={effectiveDrawerCallbacks} goalNotifications={model.goalNotifications ?? []} goals={workspaceGoals} inspectorExpanded={taskInspectorExpanded} larkConnections={readOnly ? [] : larkConnections}
+ drawer={drawerSelection ? setSelection({ kind: "attention", item })} callbacks={effectiveDrawerCallbacks} goalNotifications={model.goalNotifications ?? []} goals={workspaceGoals} inspectorExpanded={taskInspectorExpanded} larkConnections={drawerReadOnly ? [] : larkConnections}
todoReadbackUnavailable={drawerSelection.kind === "todo" && !workspaceGoals.some((goal) =>
goal.goalId === drawerSelection.item.goalId && !goal.loadState && (drawerSelection.item.detailMode !== undefined || drawerSelection.item.done
|| goal.agentTodos.some((todo) => todo.todoId === drawerSelection.item.todoId)))}
@@ -2794,7 +2842,7 @@ export function PersonalWorkspacePage({
}
setTaskInspectorExpanded(false);
setSelection(null);
- }} onToggleInspectorSize={() => setTaskInspectorExpanded((current) => !current)} readOnly={readOnly} runs={items.flatMap((item) => item.kind === "run" ? [item.run] : [])} selection={drawerSelection} /> : null}
+ }} onToggleInspectorSize={() => setTaskInspectorExpanded((current) => !current)} readOnly={drawerReadOnly} runs={items.flatMap((item) => item.kind === "run" ? [item.run] : [])} selection={drawerSelection} /> : null}
drawerMode={drawerSelection?.kind === "todo" ? (taskInspectorExpanded ? "inspector-full" : "inspector") : "panel"}
drawerOpen={drawerSelection !== null}
mobileSidebarOpen={mobileSidebarOpen}
@@ -2929,7 +2977,8 @@ export function PersonalWorkspacePage({
: void callbacks.onRefresh?.("missing")}>{t("startup.retry")} : null}
) : selectedGoal ? (
-
+ setRailScope(null)} digest={workspaceDigest} review={workspaceReview} active={!settingsOpen && selectedGoalTab === "overview"} goal={selectedGoal} items={items} userTodos={model.userTodos} readOnly={readOnly}
onOpenDetails={() => setSelection({ kind: "goal", item: selectedGoal })} onSelect={setSelection} onView={setSelectedGoalTab}
@@ -2945,7 +2994,7 @@ export function PersonalWorkspacePage({
sessionId={selectedWorkSessionId} onSession={(id, replace) => callbacks.onSelectWorkSession?.(id, replace)}
onSelect={id => callbacks.onSelectWorkAgent?.(id)} onClose={() => callbacks.onSelectWorkAgent?.(null)}>
{workspaceConversations?.managed?.error ? {workspaceConversations.managed.error} {t("history.retry")}
: null}
- item.kind === "message")} onSelect={setSelection} selectedGoal={selectedGoal}
+ item.kind === "message")} onSelect={setSelection} selectedGoal={selectedGoal}
onAdjustTurn={steeringTurnId ? focusTurnComposer : undefined}
onCancelPreparation={!readOnly && callbacks.onCancelConversationPreparation ? () => callbacks.onCancelConversationPreparation!(conversationKey) : undefined}
onInterruptTurn={!readOnly && callbacks.onInterruptConversationTurn ? turnId => callbacks.onInterruptConversationTurn!(conversationKey, turnId) : undefined}/>
@@ -2957,7 +3006,7 @@ export function PersonalWorkspacePage({
run={activeSessionRun}
/>
) : null}
- callbacks.onCancelConversationPreparation!(selectedGoal.goalId) : undefined}
@@ -2966,14 +3015,16 @@ export function PersonalWorkspacePage({
: undefined} />
>),
}} />
+
) : !managerChatOpen ? (
- void callbacks.onRefresh?.("missing")} onRetrySource={() => void refreshWorkspace()} onSelectGoal={selectGoal} systemHealth={model.systemHealth}
+ setSelection({kind: "attention", item})}
+ onRetry={() => void callbacks.onRefresh?.("missing")} onRetrySource={() => void refreshWorkspace()} onSelectGoal={selectGoal} systemHealth={model.systemHealth}
onboarding={model.onboarding} onRequestGoalCreate={readOnly ? undefined : requestGoalCreate} onSuggestRequest={readOnly ? undefined : suggestReply}
runtimeReadiness={readOnly ? undefined : runtimeReadiness} recheckingRuntimes={recheckingRuntimes} onRecheckRuntimes={callbacks.onRecheckRuntimes ? recheckRuntimes : undefined}
operations={actionReadback.isError ? [] : homeOperations} onSelectOperation={proposal => setSelection({kind: "proposal", item: proposal})}
onViewAllOperations={() => setSelectedGoalTab("chat")} />
) : (
- item.kind === "message") : managerChatItems}
onSelect={setSelection} selectedGoal={null} showManagerTeamResults={!selectedWorkspaceRef && !originalConversation}
onAdjustTurn={steeringTurnId ? focusTurnComposer : undefined}
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 510220279b..f095d45924 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css
+++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css
@@ -387,21 +387,19 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
.personal-runtime-readiness-line { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; min-width: 0; overflow-wrap: anywhere; color: var(--pw-amber); }
.personal-runtime-readiness-line button { min-height: 28px; padding: 0 10px; border: 1px solid var(--pw-amber); border-radius: 6px; background: transparent; color: var(--pw-amber); font-size: var(--text-caption); cursor: pointer; }
.personal-runtime-readiness-line a { color: var(--pw-blue-ink); }
-.personal-home-lane { display: flex; flex-direction: column; min-width: 0; min-height: 100%; border: 1px solid var(--pw-line); border-radius: 16px; background: #fff; box-shadow: 0 1px 4px rgb(30 28 20 / 4%); }
.personal-manager-greeting > span { display: grid; flex: none; place-items: center; width: 44px; height: 44px; border-radius: 12px; background: var(--pw-blue-soft); color: var(--pw-blue); }
.personal-manager-greeting div { display: grid; gap: 4px; min-width: 0; }
.personal-manager-greeting strong { font-size: var(--text-heading); line-height: var(--leading-heading); letter-spacing: -0.01em; }
.personal-manager-greeting p { margin: 0; color: var(--pw-muted); font-size: var(--text-body); line-height: var(--leading-body); }
.personal-proposal-explainer { padding: 10px 12px; border-radius: 10px; background: var(--pw-blue-soft); color: var(--pw-text); font-size: var(--text-caption); line-height: var(--leading-caption); }
-.personal-home-board { display: grid; gap: 12px; min-width: 0; }
-.personal-home-lanes { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); gap: 10px; min-width: 0; }
+.personal-home-board { display: grid; gap: 24px; min-width: 0; container-type: inline-size; }
+.personal-home-lanes { display: grid; grid-template-columns: minmax(0, 1fr); gap: 24px; min-width: 0; }
.personal-brief-date { color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; }
-.personal-brief { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.6fr); align-items: start; gap: 24px; min-width: 0; }
+.personal-brief { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); align-items: start; gap: 24px; min-width: 0; }
.personal-brief:has(> .personal-brief-tile:nth-child(4)) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
-.personal-brief:has(> .personal-brief-tile:nth-child(5)) > .personal-brief-tile:last-child { grid-column: 1 / -1; }
.personal-brief-tile { display: grid; grid-template-rows: auto auto; gap: 12px; min-width: 0; padding: 0; border: 0; background: transparent; }
.personal-brief-tile > header { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding-bottom: 12px; border-bottom: 1px solid var(--pw-line); }
-.personal-brief-tile > header span { color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); font-weight: 500; }
+.personal-brief-tile > header span { color: var(--pw-muted); font-size: var(--text-body); line-height: var(--leading-body); font-weight: 500; }
.personal-brief-tile > header b { color: var(--pw-text); font-size: var(--text-display); font-weight: 600; line-height: 1; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }
.personal-brief-tile[data-empty] > header b { color: var(--pw-faint); }
.personal-brief-tile.is-needs:not([data-empty]) > header b { color: var(--pw-amber); }
@@ -413,11 +411,14 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
.personal-brief-row strong, .personal-brief-row small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.personal-brief-row strong { font-size: var(--text-body); line-height: var(--leading-body); font-weight: 500; }
.personal-brief-row small { color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); }
-.personal-brief-row > svg { color: var(--pw-faint); opacity: 0; transition: opacity 120ms ease, transform 120ms ease; }
-.personal-brief-row:hover > svg, .personal-brief-row:focus-visible > svg { opacity: 1; transform: translateX(2px); }
+.personal-brief-row > svg:first-child { color: var(--pw-caution); }
+.personal-brief-row > svg:last-child { color: var(--pw-faint); opacity: 0; transition: opacity var(--motion-fast) var(--ease-out), transform var(--motion-fast) var(--ease-out); }
+.personal-brief-row:hover > svg:last-child, .personal-brief-row:focus-visible > svg:last-child { opacity: 1; transform: translateX(2px); }
.personal-brief-check { display: grid; place-items: center; width: 22px; height: 22px; border-radius: 999px; background: var(--pw-green-bg); color: var(--pw-green); }
.personal-brief-empty { align-self: start; margin: 0; color: var(--pw-muted); font-size: var(--text-body); line-height: var(--leading-body); }
-.personal-brief-tile > footer { color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); border-top: 1px solid var(--pw-line); padding-top: 8px; }
+.personal-brief-tile > footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 4px 12px; color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); border-top: 1px solid var(--pw-line); padding-top: 8px; }
+.personal-brief-tile > footer button { min-height: 44px; padding: 4px 0; background: transparent; border: 0; color: var(--pw-text); font: inherit; cursor: pointer; text-decoration: underline; text-underline-offset: 3px; }
+.personal-brief-tile > footer button:focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 3px; }
.personal-home-section-title { margin: 10px 0 0; color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); font-weight: 500; }
@media (min-width: 721px) and (max-width: 1100px) {
.personal-brief { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -428,7 +429,7 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
.personal-brief-tile { grid-column: 1 / -1; }
}
@media (prefers-reduced-motion: reduce) { .personal-brief-row > svg { transition: none; } }
-.personal-home-lane { display: grid; align-content: start; min-width: 0; min-height: 0; padding: 12px; border: 1px solid var(--pw-line); border-radius: 15px; background: rgb(255 255 255 / 52%); }
+.personal-home-lane { display: grid; align-content: start; gap: 8px; min-width: 0; min-height: 0; padding: 0; border: 0; border-radius: 0; background: transparent; }
.personal-home-lane > header { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.personal-home-lane > header span { display: inline-flex; align-items: center; gap: 7px; font-size: var(--text-body); line-height: var(--leading-body); font-weight: 600; }
.personal-home-lane > header i { width: 8px; height: 8px; border-radius: 50%; background: var(--pw-faint); }
@@ -436,15 +437,15 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
.personal-home-lane > p { min-height: 34px; margin: 7px 0 10px; color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); }
.personal-home-lane.is-needs_you > header i { background: var(--pw-amber); }
.personal-home-lane.is-running > header i { background: var(--pw-green); }
-.personal-home-lane.is-claimed > header i { background: #0f8f83; }
+.personal-home-lane.is-claimed > header i { background: var(--pw-muted); }
.personal-home-lane.is-observing > header i { background: var(--pw-blue); }
-.personal-home-lane.is-scheduled > header i { background: #8b6bd8; }
-.personal-home-lane-list { display: grid; align-content: start; gap: 9px; }
-.personal-home-goal-card { display: grid; grid-template-columns: minmax(0, 1fr); gap: 7px; width: 100%; min-width: 0; padding: 12px; border: 1px solid var(--pw-line); border-radius: 12px; background: #fff; color: inherit; cursor: pointer; text-align: left; box-shadow: 0 1px 4px rgb(30 28 20 / 6%); transition: border-color .14s ease, transform .14s ease, box-shadow .14s ease; }
-.personal-home-goal-card:hover { border-color: var(--pw-line-strong); transform: translateY(-1px); box-shadow: 0 4px 12px rgb(30 28 20 / 8%); }
-.personal-home-goal-card:focus-visible { outline: 2px solid #7da2ff; outline-offset: 2px; }
+.personal-home-lane.is-scheduled > header i { background: var(--pw-muted); }
+.personal-home-lane-list { display: grid; align-content: start; gap: 0; }
+.personal-home-goal-card { display: grid; grid-template-columns: minmax(0, 1fr); gap: 7px; width: 100%; min-width: 0; padding: 12px 8px; border: 0; border-bottom: 1px solid var(--pw-line); border-radius: 0; background: transparent; color: inherit; cursor: pointer; text-align: left; box-shadow: none; transition: background-color var(--motion-fast) var(--ease-standard); }
+.personal-home-goal-card:hover { background: var(--pw-hover); }
+.personal-home-goal-card:focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 2px; }
.personal-home-goal-meta { display: flex; align-items: center; gap: 6px; min-width: 0; color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.personal-home-goal-card > p { display: -webkit-box; margin: 0; overflow: hidden; color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
+.personal-home-goal-card > p { display: -webkit-box; margin: 0; overflow: hidden; color: var(--pw-muted); font-size: var(--text-body); line-height: var(--leading-body); -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow-wrap: anywhere; }
.personal-home-goal-card > footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; padding-top: 2px; }
.personal-home-goal-card > footer .personal-goal-activity-chip { flex: 1 1 0; min-width: 0; font-size: var(--text-caption); line-height: var(--leading-caption); }
.personal-home-goal-card > footer .personal-goal-activity-chip > span:last-child { min-width: 0; }
@@ -457,6 +458,13 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
.personal-home-history > summary b { color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); }
.personal-home-history > summary small { margin-left: auto; color: var(--pw-faint); font-size: var(--text-caption); line-height: var(--leading-caption); }
.personal-home-history > div { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 9px; padding: 0 12px 12px; }
+@container (min-width: 720px) {
+ .personal-home-lane-list > .personal-home-goal-card { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: 24px; }
+ .personal-home-lane-list > .personal-home-goal-card > .personal-home-goal-title { grid-column: 1; grid-row: 1; }
+ .personal-home-lane-list > .personal-home-goal-card > .personal-home-goal-meta { grid-column: 1; grid-row: 2; }
+ .personal-home-lane-list > .personal-home-goal-card > p { grid-column: 2; grid-row: 1; }
+ .personal-home-lane-list > .personal-home-goal-card > footer { grid-column: 2; grid-row: 2; }
+}
.personal-session-record { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px 16px; margin-bottom: 12px; padding: 14px 16px; border: 1px solid #cfdcf9; border-radius: 14px; background: var(--pw-blue-soft); }
.personal-session-record > header { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.personal-session-record > header span { display: inline-flex; align-items: center; gap: 7px; color: var(--pw-blue-ink); font-size: var(--text-caption); line-height: var(--leading-caption); font-weight: 600; }
@@ -1743,11 +1751,11 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
.personal-settings-page[data-pw-theme="loopx"] .personal-lark-app-card,
.personal-settings-page[data-pw-theme="loopx"] .personal-lark-table,
.personal-settings-page[data-pw-theme="loopx"] .personal-lark-topic-panel { border-radius: 12px; box-shadow: none; }
-.personal-workspace-shell[data-pw-theme="loopx"] .personal-home-lanes { gap: 0; border-block: 1px solid #ebebeb; }
-.personal-workspace-shell[data-pw-theme="loopx"] .personal-home-lane { min-height: 0; padding: 16px; border: 0; border-left: 1px solid #ebebeb; border-radius: 0; background: transparent; }
+.personal-workspace-shell[data-pw-theme="loopx"] .personal-home-lanes { gap: 24px; }
+.personal-workspace-shell[data-pw-theme="loopx"] .personal-home-lane { min-height: 0; padding: 0; border: 0; border-radius: 0; background: transparent; }
.personal-workspace-shell[data-pw-theme="loopx"] .personal-home-lane:first-child { border-left: 0; }
-.personal-workspace-shell[data-pw-theme="loopx"] .personal-home-goal-card { border-radius: 12px; box-shadow: none; transform: none; }
-.personal-workspace-shell[data-pw-theme="loopx"] .personal-home-goal-card:hover { border-color: #a1a1a1; box-shadow: none; transform: none; }
+.personal-workspace-shell[data-pw-theme="loopx"] .personal-home-goal-card { border-radius: 0; box-shadow: none; transform: none; }
+.personal-workspace-shell[data-pw-theme="loopx"] .personal-home-goal-card:hover { border-color: var(--pw-line); box-shadow: none; transform: none; }
.personal-workspace-shell[data-pw-theme="loopx"] .personal-channel-timeline { gap: 8px; }
.personal-workspace-shell[data-pw-theme="loopx"] .personal-timeline-row,
.personal-workspace-shell[data-pw-theme="loopx"] .personal-schedule-row { border-radius: 12px; box-shadow: none; }
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/research-record-comparison.tsx b/apps/presentation/dashboard/src/features/personal-workspace/research-record-comparison.tsx
index 0a2266ff76..ca9ac689a2 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/research-record-comparison.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/research-record-comparison.tsx
@@ -1,4 +1,4 @@
-import {useState, type ReactNode} from "react";
+import {useEffect, useRef, useState, type ReactNode} from "react";
import type {ExploreHistoryEvent} from "../../../../../../loopx/control_plane/presentation/explore_history.ts";
import {changedResearchFields, researchComparisonFields, researchRecordVersions, type ResearchComparisonField} from "./research-record-versions.ts";
import {ExploreMeasurements} from "./explore-measurements";
@@ -26,6 +26,22 @@ export function ResearchRecordComparison({events, selected, displayed, renderSum
const [chosen, setChosen] = useState("");
const [showAll, setShowAll] = useState(false);
const [open, setOpen] = useState(false);
+ const disclosure = useRef(null);
+ const returnFrame = useRef(null);
+ useEffect(() => () => {if (returnFrame.current !== null) cancelAnimationFrame(returnFrame.current);}, []);
+ const returnToRecord = () => {
+ const node = disclosure.current;
+ const record = node?.closest(".research-record-entry");
+ const target = record?.querySelector(":scope > summary") ?? node?.querySelector(":scope > summary");
+ setOpen(false);
+ if (returnFrame.current !== null) cancelAnimationFrame(returnFrame.current);
+ returnFrame.current = requestAnimationFrame(() => {
+ returnFrame.current = null;
+ if (!node?.isConnected || node.open || !target?.isConnected) return;
+ target.focus({preventScroll: true});
+ target.scrollIntoView({block: "nearest"});
+ });
+ };
const comparison = alternatives.find(event => event.event_id === chosen)
?? versions[index - 1] ?? alternatives[0];
if (!comparison) return null;
@@ -45,7 +61,23 @@ export function ResearchRecordComparison({events, selected, displayed, renderSum
{typeof raw === "object" ? {JSON.stringify(raw, null, 2)} : {String(raw)}
}
: null}>;
};
- return setOpen(event.currentTarget.open)}>
+ return setOpen(event.currentTarget.open)}
+ onKeyDown={event => {
+ if (event.key !== "Escape" || event.defaultPrevented || !open) return;
+ event.stopPropagation();
+ const target = event.target instanceof Element ? event.target : null;
+ // The native select owns its popup dismissal; optional inner originals
+ // close one layer at a time without leaving the current comparison.
+ if (target?.closest("select")) return;
+ event.preventDefault();
+ const nested = target?.closest("details");
+ if (nested && nested !== event.currentTarget && nested.open) {
+ nested.open = false;
+ nested.querySelector(":scope > summary")?.focus();
+ return;
+ }
+ returnToRecord();
+ }}>
{zh ? "比较记录版本" : "Compare recorded versions"} · {versions.length}
{open ? <>
;
}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/result-catalog.css b/apps/presentation/dashboard/src/features/personal-workspace/result-catalog.css
new file mode 100644
index 0000000000..9a9e4fdbea
--- /dev/null
+++ b/apps/presentation/dashboard/src/features/personal-workspace/result-catalog.css
@@ -0,0 +1,12 @@
+.goal-research-results { min-width: 0; overflow-wrap: anywhere; }
+.goal-team-results.is-catalog .goal-team-results-layout { grid-template-columns: minmax(0, 1fr); gap: 16px; }
+.goal-team-results.is-catalog .goal-team-result-list { flex-direction: column; overflow: visible; max-height: none; padding: 0; }
+.goal-team-results.is-catalog .goal-team-result-list[hidden] { display: none; }
+.goal-team-results.is-catalog .goal-team-result-list .result-catalog-row { width: 100%; max-width: none; flex: initial; padding: 16px; text-align: start; }
+.goal-team-results .result-catalog-row > span { flex: 1; min-width: 0; }
+.goal-team-results .result-catalog-row strong { display: block; overflow: visible; overflow-wrap: anywhere; -webkit-line-clamp: initial; }
+.goal-team-results .result-catalog-row small { font-family: inherit; overflow-wrap: anywhere; }
+.result-catalog-meta { display: flex; flex-wrap: wrap; gap: 4px 16px; color: var(--pw-muted); font: var(--text-caption)/var(--leading-caption) var(--font-sans); }
+.goal-team-results.is-catalog .goal-team-result-reader { border: 0; padding: 0; min-width: 0; }
+.goal-team-results .result-catalog-return { display: inline-flex; align-items: center; gap: 8px; margin-bottom: 16px; }
+.goal-team-results.is-catalog :is([data-result-catalog], [data-result-reader]):focus { outline: 2px solid var(--pw-blue-ink, #0070f3); outline-offset: 4px; }
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/result-catalog.tsx b/apps/presentation/dashboard/src/features/personal-workspace/result-catalog.tsx
new file mode 100644
index 0000000000..4aa50810ac
--- /dev/null
+++ b/apps/presentation/dashboard/src/features/personal-workspace/result-catalog.tsx
@@ -0,0 +1,54 @@
+import {useEffect, useRef} from "react";
+import {ArrowLeft, FileText} from "lucide-react";
+import "./result-catalog.css";
+
+/** Presentation only: every owner supplies its native identity and status. */
+export function ResultCatalogRow({resultKey, title, source, status, time, zh, selected, disabled, label, onSelect}: {
+ resultKey: string; title: string; source: string; status: string; time?: string | null; zh: boolean;
+ selected?: boolean; disabled?: boolean; label?: string; onSelect: () => void;
+}) {
+ const date = time && Number.isFinite(Date.parse(time))
+ ? new Intl.DateTimeFormat(zh ? "zh-CN" : "en", {year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit"}).format(new Date(time))
+ : time;
+ return {
+ event.currentTarget.focus({preventScroll: true}); onSelect();
+ }}>
+ {title}
+ {source}{status}
+ {time ? : null}
+
+ ;
+}
+
+export function ResultCatalogReturn({zh, onReturn}: {zh: boolean; onReturn: () => void}) {
+ return
+ {zh ? "返回成果目录" : "Return to result catalog"}
+ ;
+}
+
+/** Keep focus local while each result owner retains its own read/cancel lifecycle. */
+export function useResultCatalogFocus() {
+ const root = useRef(null);
+ const frame = useRef(null);
+ const cancel = () => {
+ if (frame.current !== null) window.cancelAnimationFrame(frame.current);
+ frame.current = null;
+ };
+ useEffect(() => cancel, []);
+ function focus(reader: boolean, key?: string | null) {
+ cancel();
+ frame.current = window.requestAnimationFrame(() => {
+ frame.current = null;
+ if (!root.current?.isConnected) return;
+ const row = key ? [...root.current.querySelectorAll("[data-result-key]")]
+ .find(button => button.dataset.resultKey === key && !button.disabled) : null;
+ const target = reader ? root.current.querySelector("[data-result-reader]")
+ : row ?? root.current.querySelector("[data-result-catalog]") ?? root.current;
+ if (!target?.getClientRects().length) return;
+ target.focus({preventScroll: true});
+ target.scrollIntoView({block: "nearest"});
+ });
+ }
+ return {root, cancel, reader: () => focus(true), row: (key?: string | null) => focus(false, key)};
+}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx b/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx
index 86654a5877..6205c7fb0a 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx
@@ -1,4 +1,5 @@
-import {useEffect, useRef, useState} from "react";
+import {useEffect, useId, useRef, useState} from "react";
+import {compactPythonWhitespace, compactPythonTextLength} from "../../../../../../loopx/control_plane/coordination/python_whitespace.ts";
import {readTodoRequest} from "../../data/chat";
import type {TodoUpdateSourceBasis} from "../../data/action-source-basis";
import {useWorkspaceI18n} from "./i18n";
@@ -13,7 +14,8 @@ type RequestRead = {identity: string} & (
export function TaskRequest({todo, local, sourceNamespace, onSave}: {todo: WorkspaceTodo; local: boolean; sourceNamespace?: string;
onSave?: PersonalWorkspaceCallbacks["onSaveTodoRequirements"]}) {
- const {t} = useWorkspaceI18n();
+ const {locale, t} = useWorkspaceI18n();
+ const countId = useId();
const sourceId = todo.sourceTodoId;
const identity = JSON.stringify([local, sourceNamespace, todo.goalId, sourceId, todo.requestText, todo.status]);
const [read, setRead] = useState(null);
@@ -50,12 +52,13 @@ export function TaskRequest({todo, local, sourceNamespace, onSave}: {todo: Works
const detail = current?.phase === "ready" ? current.detail : null;
const editable = local && onSave && todo.claimedBy && sourceId && detail?.review_basis
&& detail.archive_state === "active" && detail.status !== "done";
+ const draftLength = compactPythonTextLength(draft?.text ?? "");
async function save() {
- if (!onSave || !draft || !sourceId || savingRef.current) return;
+ if (!onSave || !draft || !sourceId || !editable || savingRef.current || !draftLength || draftLength > 8000) return;
savingRef.current = true;
setSaving(true); setFailure(null); setSaved(false);
try {
- const text = draft.text.replace(/\s+/gu, " ").trim();
+ const text = compactPythonWhitespace(draft.text);
const proposal = await onSave(todo, text, draft.basis, pending ?? undefined);
if (!mounted.current) return;
setPending(proposal);
@@ -63,7 +66,7 @@ export function TaskRequest({todo, local, sourceNamespace, onSave}: {todo: Works
throw new Error(proposal.errorMessage ?? proposal.gate?.summary
?? (proposal.reviewPlan ? t(`actionReview.${proposal.reviewPlan.reason}`) : t("drawer.requirementsNotSaved")));
}
- const observed = await readTodoRequest(todo.goalId, sourceId, new AbortController().signal);
+ const observed = await readTodoRequest(todo.goalId, sourceId, new AbortController().signal, sourceNamespace);
if (!mounted.current) return;
setRead({identity, phase: "ready", detail: observed});
if (observed.text !== text) throw new Error(t("drawer.requirementsReadbackChanged"));
@@ -86,12 +89,14 @@ export function TaskRequest({todo, local, sourceNamespace, onSave}: {todo: Works
}}>{t("drawer.requirementsEdit")} : null}
{draft ?