{zh ? `本页 ${items.length} 条委派记录,仅限当前协调身份;分页不是团队快照。` : `${items.length} delegation records on this page, scoped to this coordinator; paging is not a team snapshot.`}{page.has_more ? (zh ? " 还有下一页。" : " More pages remain.") : ""}
+
: null}
+
{zh ? `已绑定成员 · ${members.length}` : `Bound members · ${members.length}`}
{checked || inspectionTotal ?
{zh
? `${inspectionTotal ? "正在检查" : "上次检查"} ${checked}/${members.length} 名:${ready} 名满足本机启动条件,${unverified} 名运行时待核验,${blocked} 名受阻或无法读取。检查不代表已经执行。`
: `${inspectionTotal ? "Checking" : "Last check"} ${checked}/${members.length}: ${ready} meet local launch prerequisites, ${unverified} runtimes unverified, ${blocked} blocked or unreadable. Inspection does not mean execution.`}
+ : {zh ? "本页没有它的委派记录" : "No records for this member on this page"} : null}
+ {zh ? "任务与执行配置" : "Task and execution details"}{member.todo_id}{checks[member.id]?.executor?.profile ? {checks[member.id]?.executor?.profile} : null}
+ {records.map(row => {row.operation_id ?? row.record_id})}
+
;
+ })}
{zh ? "此协调身份的持久工作" : "Durable work for this coordinator"}
-
- {page?.has_more && page.next_cursor ? : null}
{zh ? "仅限当前协调身份;分页不是团队快照。" : "Scoped to this coordinator; paging is not a team snapshot."}{page.has_more ? (zh ? " 还有下一页。" : " More pages remain.") : ""}
+ {!page.page_readback_complete ?
{zh ? "本页有无法核验的工作,请检查原请求;不要直接重新派工。" : "Some work cannot be verified. Reconcile the original request before redispatching."}
: null}
+ {!items.length ?
{zh ? "此页没有委派记录;不代表整个团队没有工作或 Goal 已完成。" : "No records on this page; this does not establish an idle team or a completed Goal."}
;
diff --git a/examples/personal-workspace-browser/loopx-mode.mjs b/examples/personal-workspace-browser/loopx-mode.mjs
index 0ced534943..4162742cc7 100644
--- a/examples/personal-workspace-browser/loopx-mode.mjs
+++ b/examples/personal-workspace-browser/loopx-mode.mjs
@@ -66,8 +66,14 @@ export const loopxModeScenario = {
if (api.loopxModeRequests.some(row => row.operation === "inspect")) throw new Error("Reading results ran member preflight");
await page.getByRole("button", {name: "团队执行情况", exact: true}).click();
const team = page.getByRole("region", {name: "团队执行详情"});
- await team.getByText("local-analyst · 已通过当前验收", {exact: true}).waitFor();
+ const memberCard = name => team.locator(".goal-team-bindings > li").filter({hasText: name});
+ const pulseCount = bucket => team.locator(`.goal-team-pulse li[data-bucket="${bucket}"] strong`).innerText();
+ await memberCard("local-analyst").getByText("已通过当前验收", {exact: true}).waitFor();
await team.getByText("本页有无法核验的工作,请检查原请求;不要直接重新派工。", {exact: true}).waitFor();
+ await team.locator(".goal-team-operations").getByText("记录不可读 · 无法核验", {exact: true}).waitFor();
+ if (await pulseCount("accepted") !== "1" || await pulseCount("attention") !== "1" || await pulseCount("executing") !== "0") {
+ throw new Error("Team pulse did not count the page's typed delegation states");
+ }
await team.getByRole("button", {name: "检查整个团队", exact: true}).click();
await team.getByText(/运行时可用性尚未验证/).waitFor();
await team.getByText("上次检查 3/3 名:1 名满足本机启动条件,1 名运行时待核验,1 名受阻或无法读取。检查不代表已经执行。", {exact: true}).waitFor();
@@ -78,8 +84,10 @@ export const loopxModeScenario = {
}
await page.screenshot({path: resolve(outputDir, "goal-team-execution-desktop.png"), fullPage: false, animations: "disabled"});
await team.getByRole("button", {name: "下一页", exact: true}).click();
- await team.getByText("cloud-reviewer · 需要恢复原执行", {exact: true}).waitFor();
- if (await team.getByText("cloud-reviewer · 执行中", {exact: true}).count()) throw new Error("Stopped worker was labeled executing");
+ await memberCard("cloud-reviewer").getByText("需要恢复原执行", {exact: true}).waitFor();
+ if (await memberCard("cloud-reviewer").getByText("执行中", {exact: true}).count() || await pulseCount("executing") !== "0") {
+ throw new Error("Stopped worker was labeled executing");
+ }
await page.setViewportSize({width: 390, height: 844});
await page.screenshot({path: resolve(outputDir, "goal-team-execution-mobile.png"), fullPage: false, animations: "disabled"});
if (api.turnRequests.length) throw new Error("Inspecting the team started a model turn");
From e943bae16af3192f72ec4a7ba9a52b5b6081afa2 Mon Sep 17 00:00:00 2001
From: huangruiteng
Date: Thu, 1 Oct 2026 21:58:03 +0800
Subject: [PATCH 3/4] docs(design): codify owner-facing state rules
Anchor the product character in owner value and add four rules: decide in
place with readback, liveness from observation, group by responsibility,
and owner language first; plus the count-strip and owner-card patterns.
Signed-off-by: huangruiteng
---
docs/development/design.md | 37 +++++++++++++++++++++++++++++++++++++
1 file changed, 37 insertions(+)
diff --git a/docs/development/design.md b/docs/development/design.md
index fe77acb5b7..b7a1b0301f 100644
--- a/docs/development/design.md
+++ b/docs/development/design.md
@@ -24,6 +24,13 @@ LoopX should feel:
The interface should read like excellent technical documentation that also
communicates a confident product.
+LoopX's value to an owner is delegating long-running work to Agents without
+losing sight or control of it. Every surface should answer, at a glance, what
+is moving, what is waiting on the owner, and what can be trusted, and let the
+owner act on the answer in place. The same honesty is what makes LoopX credible
+to people outside the project: a screenshot of LoopX should never overstate
+progress.
+
### Earn The User's Attention
Every visible element must provide at least one of these:
@@ -45,6 +52,26 @@ Do not collapse failures, missing authority, uncertain observations, or actions
requiring judgment into a reassuring success summary. Read models select facts;
they must not invent execution, acceptance, or completion from prose or counts.
+Owner-facing state follows four rules:
+
+- **Decide in place.** When LoopX already owns the authority for an owner
+ decision, the surface performs it: choose, confirm once with the stated
+ effect, then read back the recorded outcome and its effect on dependent work.
+ Do not hand the owner a CLI command to copy, a generic "apply" step, or a
+ control whose effect differs from its label. When an action is truly
+ unavailable here, say why in plain language and name where it can be done.
+- **Liveness comes from observation.** Live treatment (a pulsing dot,
+ "executing", "running") requires an observed active worker, session or turn.
+ A stored status, a registration or a count is not activity; unknown stays
+ visibly unknown.
+- **Group by responsibility.** Collaboration surfaces place work under the
+ member, Agent or owner responsible for it, so one glance shows who is doing
+ what and who is blocked. Work with no resolvable owner goes into an explicit
+ "other" group; it is never dropped.
+- **Owner language first.** Primary copy uses the owner's words. Protocol ids,
+ gate kinds, preview mechanics and executor profiles belong in a collapsed
+ diagnostics disclosure.
+
Review realistic populated, quiet, blocked, and unavailable states on desktop
and mobile. Show before/after views, name what earns attention and what was
consolidated, and verify keyboard access and return to the original context.
@@ -228,6 +255,14 @@ Do not mix marketing pills and application squares in the same control group.
- Prefer compact badges and hairline panels.
- Keep goal, gate, owner, evidence, risk, budget, and next action visually
distinguishable.
+- Summarize many records with one hairline count strip of typed states: zero
+ counts recede, states that need the owner take the danger color and an icon,
+ and the scope of the counts (for example "this page only") sits next to it.
+ Counts come from the same typed classification as the labels, never from
+ prose.
+- Detail many owners with a grid of owner cards: identity, one status pill, the
+ owner's own records, and identifiers behind a disclosure. A blocked owner's
+ card may carry a tinted hairline; avoid filling the card with color.
- Never render raw private state, credentials, provider IDs, or machine paths.
## Motion
@@ -306,6 +341,8 @@ parity is required. Framework migration alone must not alter the UI.
- [ ] Marketing and application controls use the correct shape language.
- [ ] Desktop and mobile layouts are validated.
- [ ] Keyboard, focus, contrast, and reduced-motion behavior are preserved.
+- [ ] Owner decisions complete in place with a readback; live treatment is
+ backed by an observed active fact; protocol identifiers are not primary copy.
- [ ] English and Chinese content remain usable.
- [ ] Screenshots are provided for first-screen or fidelity-sensitive changes.
- [ ] No private data or local paths enter the UI or screenshots.
From 00d99821bb305fbe3be12c6a76bf48f8ba1622da Mon Sep 17 00:00:00 2001
From: huangruiteng
Date: Thu, 1 Oct 2026 23:42:02 +0800
Subject: [PATCH 4/4] fix(app): partition team records by unique task binding
Signed-off-by: huangruiteng
---
.../personal-workspace/goal-team-work.tsx | 16 +++++-
.../personal-workspace-browser/loopx-mode.mjs | 57 ++++++++++++++++++-
2 files changed, 69 insertions(+), 4 deletions(-)
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-work.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-work.tsx
index 2208207691..fe9953c006 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-work.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-work.tsx
@@ -120,8 +120,15 @@ export function GoalTeamWork({sessionId, members, zh, canMessage, ingress}: {ses
;
const items = page?.items ?? [];
- const boundAgents = new Set(members.map(member => member.agent_id));
- const unboundRecords = items.filter(row => !row.agent_id || !boundAgents.has(row.agent_id));
+ const memberRecords = new Map(members.map(member => [member.id, [] as DelegationRecord[]]));
+ const unboundRecords: DelegationRecord[] = [];
+ for (const row of items) {
+ // Inventory has no binding id. Only a unique Agent/Todo match establishes
+ // ownership; historical, incomplete and ambiguous records remain visible.
+ const matches = members.filter(member => member.agent_id === row.agent_id && member.todo_id === row.todo_id);
+ if (matches.length === 1) memberRecords.get(matches[0].id)!.push(row);
+ else unboundRecords.push(row);
+ }
const pulse = items.reduce((counts, row) => {counts[PULSE_BUCKETS[delegationState(row)]] += 1; return counts;},
{executing: 0, validating: 0, accepted: 0, attention: 0, dispatched: 0, unknown: 0} as Record);
const visibleBuckets = (Object.keys(PULSE_LABELS) as PulseBucket[]).filter(bucket => bucket !== "unknown" || pulse.unknown > 0);
@@ -131,6 +138,9 @@ export function GoalTeamWork({sessionId, members, zh, canMessage, ingress}: {ses
return