diff --git a/apps/presentation/dashboard/smoke/todo-ownership-smoke.mjs b/apps/presentation/dashboard/smoke/todo-ownership-smoke.mjs
index c05641e689..dd5d482c1c 100644
--- a/apps/presentation/dashboard/smoke/todo-ownership-smoke.mjs
+++ b/apps/presentation/dashboard/smoke/todo-ownership-smoke.mjs
@@ -9,7 +9,7 @@ import { workspaceAgentTodoFromItem } from "../src/features/personal-workspace/p
import { ContextDrawer } from "../src/features/personal-workspace/context-drawer.tsx";
import { WorkspaceI18nProvider, useWorkspaceI18n } from "../src/features/personal-workspace/i18n.tsx";
import { ChatApiError } from "../src/data/chat.ts";
-import { todoCompletionPreviewFeedback } from "../src/features/personal-workspace/todo-completion-feedback.ts";
+import { completedActionFeedback, todoCompletionPreviewFeedback } from "../src/features/personal-workspace/todo-completion-feedback.ts";
import { RunRow } from "../src/features/personal-workspace/cards/run-row.tsx";
// A Goal's default executor is not evidence that a Todo has been claimed.
@@ -21,6 +21,17 @@ try {
const render = (component, props) => renderToStaticMarkup(createElement(
WorkspaceI18nProvider, null, createElement(component, props),
));
+ function CompletedFeedback({proposal}) {
+ return completedActionFeedback(proposal, useWorkspaceI18n().t);
+ }
+ const fullTask = "Review all evidence before external execution. ".repeat(50);
+ assert.equal(render(CompletedFeedback, {proposal: {actionKind: "todo.update", todoOperation: "complete", title: fullTask}}),
+ locale === "en" ? "Marked complete" : "已标记完成");
+ for (const proposal of [{actionKind: "todo.update", todoOperation: "cancel"},
+ {actionKind: "todo.create", todoOperation: "complete"}, {actionKind: "todo.update"}]) {
+ assert.ok(render(CompletedFeedback, {proposal: {...proposal, title: "complete"}}).includes("complete"),
+ "Only the typed completion operation changes result wording; titles never classify actions");
+ }
const completionRequest = {actionKind: "todo.update", normalizedParameters: {operation: "complete"}};
function CompletionFeedback({error, request}) {
return todoCompletionPreviewFeedback(error, request, useWorkspaceI18n().t);
diff --git a/apps/presentation/dashboard/smoke/workspace-locale-browser-smoke.mjs b/apps/presentation/dashboard/smoke/workspace-locale-browser-smoke.mjs
index 91abd4525d..0ef2a2abc0 100644
--- a/apps/presentation/dashboard/smoke/workspace-locale-browser-smoke.mjs
+++ b/apps/presentation/dashboard/smoke/workspace-locale-browser-smoke.mjs
@@ -78,9 +78,15 @@ export async function verifyWorkspaceLocales({ browser, url, installApi, outputD
assert.equal(await statusOnly.getAttribute("aria-pressed"), "true");
await page.getByLabel("Send a message to LoopX").fill("Show the current state.");
await page.getByRole("button", { name: "Send", exact: true }).click();
- await page.locator(".personal-manager-conversation-messages article.is-assistant > strong").getByText("Status only", { exact: true }).waitFor();
+ const managerViews = page.getByRole("navigation", { name: "Manager view", exact: true });
+ const overview = managerViews.getByRole("button", { name: "Overview", exact: true });
+ assert.equal(await overview.getAttribute("aria-current"), "page", "A reply preserves the selected overview");
+ assert.equal(await page.locator(".personal-manager-conversation-tray").count(), 0, "Status replies do not cover the overview");
+ await managerViews.getByRole("button", { name: "Chat", exact: true }).click();
+ await page.locator(".personal-channel-timeline article.is-assistant header strong").getByText("Status only", { exact: true }).waitFor();
assert.equal(api.turnRequests.length, 0, "Localizing the status-only route must not start an Agent Turn");
assert.ok(!(await page.locator("body").innerText()).includes("LoopX 状态投影"), "The status-only reply source follows the selected language");
+ await overview.click();
await page.setViewportSize({ width: 390, height: 844 });
await page.locator(".personal-channel-scroll").evaluate((element) => { element.scrollTop = 0; });
const clippedStats = await page.locator(".personal-digest-stats span").evaluateAll((stats) =>
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx
index a8060ebd2e..8780a616de 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx
@@ -1,4 +1,4 @@
-import { List, Bot, Eye, FolderOpen, Menu, RefreshCw, SlidersHorizontal } from "lucide-react";
+import { List, Bot, Eye, FolderOpen, LoaderCircle, Menu, RefreshCw, SlidersHorizontal } from "lucide-react";
import { GoalLifecycleControl, GoalMastheadIdentity } from "./goal-masthead";
import { useWorkspaceI18n } from "./i18n";
@@ -12,6 +12,7 @@ export function ChannelHeader({
agents,
agentWorkActive = false,
conversationScope,
+ conversationRunning = false,
goalLifecycleBusy,
managerChannelBinding,
managerChatOpen,
@@ -45,6 +46,7 @@ export function ChannelHeader({
}: {
agents: WorkspaceAgentOption[];
agentWorkActive?: boolean;
+ conversationRunning?: boolean;
/** Steward conversation scope; absent when the host grants no workspace. */
conversationScope?: { options: { disabled?: boolean; label: string; value: string }[]; value: string; onChange: (value: string) => void } | null;
goalLifecycleBusy?: boolean;
@@ -84,6 +86,11 @@ export function ChannelHeader({
}) {
const { locale, t } = useWorkspaceI18n();
const statusOnly = selectedAgentId === "status-only";
+ const chatLabel = <>{t("header.chat")}{conversationRunning ? (
+
+
+
+ ) : null}>;
const ordinaryConversation = Boolean(selectedGoal || workspaceGrantLabel);
// The chip reports the selected executor, whose credential pays for it, and
// the resolved model, so an executor and a model that disagree are visible
@@ -259,7 +266,7 @@ export function ChannelHeader({
-
+
{researchControl}
@@ -268,7 +275,7 @@ export function ChannelHeader({
) : !scopeUnavailable ? (
) : null}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-draft-card.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-draft-card.css
index 0153915064..e6d5f9fc15 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/goal-draft-card.css
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-draft-card.css
@@ -7,6 +7,3 @@
.personal-goal-draft button { min-height: 44px; padding: 8px 12px; border: 1px solid var(--pw-line-strong); border-radius: 6px; background: transparent; color: inherit; text-align: left; white-space: normal; overflow-wrap: anywhere; }
.personal-goal-draft button:focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 2px; }
.personal-goal-draft footer { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; margin-top: 16px; }
-.personal-manager-conversation-tray:has(.personal-goal-draft) { max-height: min(620px, 65dvh); }
-.personal-manager-conversation-tray:has(.personal-goal-draft) .personal-manager-conversation-messages { min-height: 0; max-height: none; }
-.personal-manager-conversation-bubble .personal-goal-draft > strong { color: var(--pw-text); font-size: var(--text-body); line-height: var(--leading-body); font-weight: 600; }
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx
index 1f19d0ae9d..6a6eadf865 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx
@@ -258,6 +258,7 @@ const en = {
"feedback.applying": "Running: {title}",
"feedback.cancelFailed": "Cancel failed: {error}",
"feedback.completed": "Completed: {title}",
+ "feedback.todoMarkedComplete": "Marked complete",
"feedback.executionFailed": "Execution failed: {error}",
"feedback.gateRequired": "Your confirmation is required: {summary}",
"feedback.notCompleted": "Not completed: {status}",
@@ -1920,6 +1921,7 @@ const zhCN: Record
= {
"feedback.applying": "正在执行:{title}",
"feedback.cancelFailed": "取消失败:{error}",
"feedback.completed": "已完成:{title}",
+ "feedback.todoMarkedComplete": "已标记完成",
"feedback.executionFailed": "执行失败:{error}",
"feedback.gateRequired": "需要你确认:{summary}",
"feedback.notCompleted": "操作未完成:{status}",
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs
index d0e55e5634..75740c6376 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs
+++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs
@@ -323,16 +323,8 @@ assert.match(attentionActions, /role="group" aria-label=\{t\("drawer\.decisionGr
assert.match(drawer, /const hasProjectedRunActivity = selection\.kind === "run"[\s\S]*selection\.item\.completedSteps > 0/, "Session empty-state copy distinguishes projected progress from a truly idle run");
assert.match(drawer, /t\("drawer\.runRecordProjected"/, "A projected run does not claim that the Agent never started");
assert.match(drawer, /t\("drawer\.runRecordEmpty"\)/, "A truly empty Session still explains why there is no timeline yet");
-assert.match(page, /function ManagerConversationTray/, "Manager home has a dedicated conversation tray instead of burying replies in the board timeline");
assert.match(page, /managerMessages/, "Manager conversation is derived from the active manager message context");
-assert.match(page, /managerConversationReceiptVisible/, "Manager conversation is a send-triggered receipt instead of permanent history chrome");
-assert.match(page, /setManagerConversationReceiptVisible\(true\)/, "A new manager send reveals the temporary conversation receipt");
-assert.match(page, /setManagerConversationReceiptVisible\(false\)/, "Opening or leaving the receipt clears it from the overview");
-assert.match(page, /personal-manager-conversation-tray/, "Manager conversation stays anchored beside the composer");
-assert.match(page, /aria-live="polite"/, "Manager conversation announces streamed replies without moving focus");
-assert.match(page, /t\("conversation\.full"\)/, "The compact manager conversation can expand in place");
-assert.match(page, /onOpenConversation/, "The compact manager conversation has a dedicated full-chat navigation action");
-assert.match(page, /managerChatOpen/, "Manager full conversation uses a dedicated Chat view instead of stretching the home tray");
+assert.match(page, /managerChatOpen/, "Manager conversation uses a dedicated Chat view");
assert.match(page, /managerChatItems/, "Manager Chat only renders conversation and confirmation items");
assert.match(page, /sessionProposalIds\.includes\(item\.proposal\.previewId\)/, "Manager Chat only shows proposals created in the current UI session");
assert.match(page, /\["preview_ready", "gated", "deferred", "applying"\]\.includes\(proposal\.status\)[\s\S]*compileActionReviewPlan\(proposal\)\.retryOriginal === true/, "Restored proposal history uses wire states and preserves retryable original operations");
@@ -347,8 +339,6 @@ assert.doesNotMatch(sidebar, /personal-manager-channels/, "The sidebar does not
assert.doesNotMatch(sidebar, /onSelectChannel/, "The sidebar only navigates to the manager or a concrete Goal");
assert.doesNotMatch(page, /selectedChannel|selectChannel\(|__manager_channel__/, "Manager state lanes do not create hidden conversation channels");
assert.match(page, /personal-digest-stats[\s\S]*t\("digest\.completed"\)/, "The away digest is a non-interactive summary");
-assert.match(page, /!selectedGoal && !managerChatOpen && managerConversationReceiptVisible[\s\S]* message\.pending\)[\s\S]*setManagerConversationReceiptVisible\(true\)/, "A recovered active Manager turn restores its compact conversation tray");
assert.match(page, /goal\.needsYou \?\? goal\.nextSentence/, "Needs-you cards keep the source Goal action visible");
assert.match(page, /onClick=\{\(\) => onSelectGoal\(goal\.goalId\)\}/, "Manager cards open their source Goal instead of a hidden lane channel");
assert.doesNotMatch(page, /managerConversationActive/, "Sending from the manager never replaces the overview with a separate conversation page");
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts
index 994f3f712b..15af4c9f5c 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts
+++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts
@@ -340,6 +340,8 @@ export type WorkspaceSchedule = {
export type WorkspaceActionPreview = {
/** Exact Todo target from the stored action, used only for display joins. */
todoId?: string;
+ /** Stored Todo operation for result wording; never grants execution authority. */
+ todoOperation?: string;
reviewPlan?: ActionReviewPlan;
actionKind:
| "goal.create"
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 a8529c61a4..a76e7e66e2 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
@@ -1,22 +1,20 @@
import {DocumentAttachment, isTextDocument} from "./document-attachment";
-import {AlertCircle, Bot, CalendarClock, CircleHelp, FileText, ListPlus, MessageCircleQuestion, Paperclip, Plus, RefreshCw, Send, Square, X} from "lucide-react";
+import {AlertCircle, Bot, CalendarClock, CircleHelp, FileText, MessageCircleQuestion, Paperclip, Plus, RefreshCw, Send, Square, X} from "lucide-react";
import {ContextHelp} from "./context-help";
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, goalDraftReady } from "./goal-create-request";
-import { todoCompletionPreviewFeedback, todoCompletionProofRequired } from "./todo-completion-feedback";
+import { completedActionFeedback, todoCompletionPreviewFeedback, todoCompletionProofRequired } from "./todo-completion-feedback";
import { readSteeringRequest, retainSteeringRequest, retireSteeringRequest, legacySteeringRequests, restoreLegacySteeringRequest } from "./steering-recovery";
import { conversationInputKey, useConversationInputState } from "./use-conversation-input-state";
import { composerKeyAction } from "./composer-keyboard";
import type { ConversationHistoryStatus } from "../../data/use-conversation-history";
import { useConversationQueue } from "../../data/use-conversation-queue";
import { ConversationQueue } from "./conversation-queue";
-import { GoalDraftCard } from "./goal-draft-card";
import type { GoalDraft } from "../../../../../../loopx/control_plane/collaboration/goal_draft.js";
-import { CollaborationCards } from "./collaboration-card";
-import {ConversationRequests, ConversationReplyContext, OriginalProjectRequests} from "./conversation-requests";
+import {ConversationRequests, OriginalProjectRequests} from "./conversation-requests";
import {
compileActionReviewPlan,
isStaleActionFailure,
@@ -55,7 +53,6 @@ import { GoalTeamResults } from "./goal-team-results";
import { GoalManagedResults } from "./goal-managed-results";
import { GoalResearchResults, type GoalResearchApi } from "./goal-research-results";
import { sendLoopXMessage, type LoopXModeSnapshot } from "../../data/chat";
-import { MessageActivity } from "./message-activity";
import { ChannelTimeline } from "./channel-timeline";
import { ContextDrawer } from "./context-drawer";
import { monitorScheduleReadback } from "./monitor-readback";
@@ -69,9 +66,7 @@ import { GoalVitals, GoalVitalsSkeleton, type GoalVitalRegion } from "./goal-mas
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 {returnedAnswerStart} from "./message-reading";
-import { ReturnDeliveryStatus } from "./return-delivery-status";
import type {
PersonalWorkspaceCallbacks,
WorkspaceAgentOption,
@@ -401,102 +396,6 @@ function GoalOutputsView({
);
}
-function ManagerConversationTray({
- agentLabel,
- messages,
- onClose,
- onDraftTask,
- onReviewGoalDraft,
- onSuggestReply,
- onOpenConversation,
- onInterruptTurn,
- onAdjustTurn,
- onCancelPreparation,
- title,
-}: {
- agentLabel?: string;
- messages: Array['message']>;
- onClose?: () => void;
- onDraftTask?: (text: string) => void;
- onReviewGoalDraft?: (draft: GoalDraft, edit?: boolean, draftId?: string) => Promise;
- onSuggestReply?: (text: string) => void;
- onOpenConversation: () => void;
- onInterruptTurn?: (turnId: string) => Promise;
- onAdjustTurn?: (turnId: string) => void;
- onCancelPreparation?: () => void;
- title?: string;
-}) {
- const { t, locale } = useWorkspaceI18n();
- const latestUserIndex = messages.reduce((latest, message, index) => message.role === "user" ? index : latest, 0);
- const latestExchange = messages.slice(Math.max(0, latestUserIndex));
- const visibleMessages = latestExchange.slice(-3);
- const activeIndex = messages.reduce((latest, message, index) => message.role === "assistant" && message.pending ? index : latest, -1);
- // A queued follow-up must not hide the current Turn's activity and controls.
- if (activeIndex >= 0 && activeIndex < latestUserIndex) visibleMessages.unshift(messages[activeIndex]);
- const latestAssistantMessage = visibleMessages.filter((item) => item.role === "assistant" && !item.pending).at(-1);
-
- useEffect(() => {
- if (!onClose) return;
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key === "Escape") onClose();
- };
- window.addEventListener("keydown", handleKeyDown);
- return () => window.removeEventListener("keydown", handleKeyDown);
- }, [onClose]);
-
- return (
-
- );
-}
-
function SessionRecordHeader({ onClose, onOpenDetails, run }: {
onClose: () => void;
onOpenDetails: () => void;
@@ -808,6 +707,8 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate)
return {
actionKind: proposal.action_kind,
todoId: typeof proposal.normalized_parameters.todo_id === "string" ? proposal.normalized_parameters.todo_id : undefined,
+ todoOperation: proposal.action_kind === "todo.update" && typeof proposal.normalized_parameters.operation === "string"
+ ? proposal.normalized_parameters.operation : undefined,
reviewPlan,
fields: proposal.action_kind === "operation.execute"
? operationProposalFields(proposal, reviewPlan, t)
@@ -943,6 +844,7 @@ export function PersonalWorkspacePage({
conversationSessionId,
conversationHistoryState,
conversationMessageFocus,
+ observedConversationRunning = false,
workBrowseSearch,
onWorkBrowseChange,
agents = [{ agentId: "codex", available: true, capability: "代码与项目执行", label: "Codex" }],
@@ -979,6 +881,7 @@ export function PersonalWorkspacePage({
conversationSessionId?: string;
conversationHistoryState?: ConversationHistoryStatus;
conversationMessageFocus?: {sessionId: string; messageId: string};
+ observedConversationRunning?: boolean;
workBrowseSearch?: Record;
onWorkBrowseChange?: (patch: Partial, source: string, replace?: boolean) => void;
agents?: WorkspaceAgentOption[];
@@ -1075,8 +978,6 @@ export function PersonalWorkspacePage({
setLocalView(view);
callbacks.onSelectView?.(view);
}
- const [managerConversationReceiptVisible, setManagerConversationReceiptVisible] = useState(false);
- const [goalConversationReceiptVisible, setGoalConversationReceiptVisible] = useState(false);
const [actionDraft, setActionDraft] = useState(null);
const [loopxMode, setLoopxMode] = useState(null);
const [loopxDelivery, setLoopxDelivery] = useState<"queue" | "inbox" | "steer">("queue");
@@ -1367,22 +1268,6 @@ export function PersonalWorkspacePage({
() => items.flatMap((item) => item.kind === "message" ? [item.message] : []),
[items],
);
- const goalMessages = useMemo(
- () => selectedGoal ? items.flatMap((item) => item.kind === "message" ? [item.message] : []) : [],
- [items, selectedGoal],
- );
- useEffect(() => {
- if (selectedGoal || managerChatOpen) return;
- if (managerMessages.some((message) => message.pending)) {
- setManagerConversationReceiptVisible(true);
- }
- }, [managerChatOpen, managerMessages, selectedGoal]);
- useEffect(() => {
- if (!selectedGoal || selectedGoalTab === "chat") return;
- if (goalMessages.some((message) => message.pending)) {
- setGoalConversationReceiptVisible(true);
- }
- }, [goalMessages, selectedGoal, selectedGoalTab]);
// One composer; ordinary sends queue, while Command+Enter explicitly steers.
const composingRef = useRef(false);
useEffect(() => { composingRef.current = false; }, [composerDraftKey]);
@@ -1466,8 +1351,7 @@ export function PersonalWorkspacePage({
onRetryHistory: conversationHistoryState?.retry,
onReadMessage: () => { followConversationRef.current = false; },
};
- const conversationMessages = selectedGoal ? goalMessages : managerMessages;
- const latestMessage = conversationMessages.at(-1);
+ const latestMessage = managerMessages.at(-1);
const latestMessageTextLength = latestMessage?.text.length ?? 0;
function scrollToLatestMessage() {
followConversationRef.current = true;
@@ -1526,7 +1410,7 @@ export function PersonalWorkspacePage({
scroller.querySelectorAll(".personal-channel-timeline").forEach(element => observer.observe(element));
}
return () => {observer.disconnect(); scroller?.removeEventListener("toggle", readQuote, true); window.cancelAnimationFrame(frame);};
- }, [conversationOpen, conversationKey, selectedAgentId, conversationMessages.length,
+ }, [conversationOpen, conversationKey, selectedAgentId, managerMessages.length,
latestMessageTextLength, latestMessage?.pending, latestMessage?.activity?.length, latestMessage?.steps]);
useEffect(() => {
const route = `${selectionScope}:${routedTodoId ?? ""}`;
@@ -1896,6 +1780,10 @@ export function PersonalWorkspacePage({
workspaceCandidates,
};
}
+ if (request.actionKind === "todo.update" && callbacks.onPreviewAction) {
+ local = { ...local, todoOperation: typeof request.normalizedParameters.operation === "string"
+ ? request.normalizedParameters.operation : undefined };
+ }
rememberSessionProposal(local.previewId, selectedGoalId);
setProposals((current) => ({ ...current, [local.previewId]: local }));
if (options.select !== false && isCurrentConversation() && selectionRevision.current === originatingSelection) {
@@ -2126,7 +2014,7 @@ export function PersonalWorkspacePage({
const applied = { ...proposal, status: "applied" as const };
setProposals((current) => ({ ...current, [proposal.previewId]: applied }));
if (showDrawer) selectReceipt(applied);
- setActionFeedback(t("feedback.completed", { title: proposal.title }));
+ setActionFeedback(completedActionFeedback(applied, t));
if (proposal.actionKind === "goal.lifecycle") {
if (proposal.lifecycleOperation === "delete" || (proposal.lifecycleOperation === "stop" && !options.keepGoalSelected)) {
if (stillSelected()) selectGoal(null);
@@ -2169,7 +2057,7 @@ export function PersonalWorkspacePage({
);
return applied;
}
- setActionFeedback(t("feedback.completed", { title: applied.title }));
+ setActionFeedback(completedActionFeedback(applied, t));
// Keep the success receipt visible for reviewed actions. Direct actions
// surface the same result through the persistent feedback receipt.
if (applied.actionKind === "todo.create" || applied.actionKind === "goal.create") {
@@ -2242,7 +2130,7 @@ export function PersonalWorkspacePage({
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 })
+ : observed.reviewPlan?.interaction === "completed" ? completedActionFeedback(observed, t)
: t("feedback.executionFailed", { error: error instanceof Error ? error.message : String(error) }));
return reconciled;
}
@@ -2264,7 +2152,6 @@ export function PersonalWorkspacePage({
function openGoalConversation() {
setSelectedGoalTab("chat");
setActiveSessionRun(null);
- setGoalConversationReceiptVisible(false);
window.requestAnimationFrame(() => {
const replies = channelScrollRef.current?.querySelectorAll(".personal-message.is-assistant");
replies?.item(replies.length - 1)?.scrollIntoView({ block: "start" });
@@ -2414,8 +2301,6 @@ export function PersonalWorkspacePage({
function selectGoal(goalId: string | null, view: WorkspaceGoalTab = "overview") {
setLocalGoalId(goalId);
- setManagerConversationReceiptVisible(false);
- setGoalConversationReceiptVisible(false);
setActiveSessionRun(null);
setSelection(null);
setLocalView(view);
@@ -2588,8 +2473,6 @@ export function PersonalWorkspacePage({
setImageAttachmentError(null);
setSending(true);
try {
- if (!selectedGoalId) setManagerConversationReceiptVisible(true);
- else if (selectedGoalTab !== "chat") setGoalConversationReceiptVisible(true);
const previews = await callbacks.onSendMessage?.(message, selectedAgentId, conversationKey, pendingImages.length ? pendingImages : undefined);
if (previews?.candidates?.length) {
const drafted = await Promise.allSettled(previews.candidates.map((request) => createPreview(request, { select: false })));
@@ -2811,7 +2694,7 @@ export function PersonalWorkspacePage({
}
}, [attentionKeySignature, attentionNotificationsActive, attentionNotificationSupportState, t]);
- const composerSuggestions = (!originalConversation || !managerChatOpen) && !selectedWorkspaceRef && (!conversationOpen || !conversationMessages.length) ?
+ const composerSuggestions = (!originalConversation || !managerChatOpen) && !selectedWorkspaceRef && (!conversationOpen || !managerMessages.length) ?
{selectedGoal && goalSuggestions.length ? (
{goalSuggestions.map((suggestion) => (
@@ -2939,6 +2822,7 @@ export function PersonalWorkspacePage({
value: selectedWorkspaceRef ?? stewardScopeValue,
} : null}
workspaceGrantLabel={selectedWorkspaceRef ? t(workspaceUnavailable ? "workspace.grantRevoked" : selectedWorkspaceProject?.grant === "workspace_write" ? "workspace.grantWrite" : "workspace.grantRead") : null}
+ conversationRunning={Boolean(observedConversationRunning || (runningMessage && !runningMessage.queued) || loopxDeliveryOpen)}
managerChatOpen={managerChatOpen}
managerChannelBinding={managerChannelBinding}
managerRuntime={managerRuntime}
@@ -2950,10 +2834,7 @@ export function PersonalWorkspacePage({
onOpenManagerSettings={!readOnly ? () => openSettings({ kind: "settings", tab: "steward" }) : undefined}
onRefresh={callbacks.onRefresh && !runtimeSourceUnavailable ? () => void refreshWorkspace() : undefined}
onOpenNavigation={() => setMobileSidebarOpen(true)}
- onOpenManagerChat={() => {
- setManagerConversationReceiptVisible(false);
- setSelectedGoalTab("chat");
- }}
+ onOpenManagerChat={() => setSelectedGoalTab("chat")}
onSelectGoalTab={(tab) => {
if (tab === "chat") openGoalConversation();
else setSelectedGoalTab(tab);
@@ -2962,7 +2843,6 @@ export function PersonalWorkspacePage({
onReturnManagerHome={() => {
if (selectedWorkspaceRef || originalConversation) workspaceConversations?.onSelect(null);
setSelectedGoalTab("overview");
- setManagerConversationReceiptVisible(false);
window.requestAnimationFrame(() => channelScrollRef.current?.scrollTo({ behavior: "smooth", top: 0 }));
}}
selectedAgentId={selectedAgentId}
@@ -3148,34 +3028,6 @@ export function PersonalWorkspacePage({
{readOnly ? (
sourceGoal?.sourceAccess ? null :
{t("source.readOnlyNoticeTitle")}{t("source.readOnlyNoticeDescription")}
) : <>
- {!selectedGoal && !managerChatOpen && managerConversationReceiptVisible && managerMessages.length ? (
-
callbacks.onCancelConversationPreparation!(conversationKey) : undefined}
- onInterruptTurn={!readOnly && callbacks.onInterruptConversationTurn ? (turnId) => callbacks.onInterruptConversationTurn!(conversationKey, turnId) : undefined}
- onAdjustTurn={steeringTurnId ? focusTurnComposer : undefined}
- messages={managerMessages}
- onClose={() => setManagerConversationReceiptVisible(false)}
- onOpenConversation={() => {
- setManagerConversationReceiptVisible(false);
- setSelectedGoalTab("chat");
- }} />
- ) : null}
- {selectedGoal && selectedGoalTab !== "chat" && goalConversationReceiptVisible && goalMessages.length ? (
- callbacks.onCancelConversationPreparation!(selectedGoal.goalId) : undefined}
- onInterruptTurn={!readOnly && callbacks.onInterruptConversationTurn ? (turnId) => callbacks.onInterruptConversationTurn!(selectedGoal.goalId, turnId) : undefined}
- onAdjustTurn={steeringTurnId ? focusTurnComposer : undefined}
- messages={goalMessages}
- onClose={() => setGoalConversationReceiptVisible(false)}
- onDraftTask={selectedGoalTab === "tasks" ? (reply) => {
- const taskDraft = reply.trim();
- setActionDraft({ kind: "todo", text: taskDraft, goalId: selectedGoalId, goalTitle: selectedGoal.title, agentId: selectedAgentId });
- } : undefined}
- onOpenConversation={openGoalConversation}
- title={`${selectedGoal.title} · ${selectedAgentLabel}`}
- />
- ) : null}
{actionFeedback ? (
{actionFeedback}
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 b5ed92ce26..7f7d441c84 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css
+++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css
@@ -246,6 +246,7 @@
.personal-channel-header { position: relative; z-index: 8; display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 74px; padding: 12px 32px; border-bottom: 1px solid var(--pw-line); background: rgb(251 250 247 / 92%); backdrop-filter: blur(12px); overflow: visible; }
.personal-goal-tabs { display: flex; align-items: center; align-self: center; gap: 2px; margin-left: auto; padding: 3px; border-radius: 10px; background: #f2f1ed; }
.personal-goal-tabs button { min-height: 30px; padding: 0 13px; border: 0; border-radius: 8px; background: transparent; color: var(--pw-muted); cursor: pointer; font-size: var(--text-body); line-height: var(--leading-body); font-weight: 500; }
+.personal-chat-activity { display: inline-flex; margin-left: 6px; vertical-align: middle; }
.personal-goal-tabs button:hover { color: var(--pw-text); }
.personal-goal-tabs button[aria-current="page"] { background: #fff; color: var(--pw-text); box-shadow: 0 1px 3px rgb(30 28 20 / 10%); }
.personal-channel-title { min-width: 0; }
@@ -1214,29 +1215,6 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
.personal-channel:has(.personal-home-board) > .personal-composer-wrap { padding-inline: max(32px, calc((100% - 960px) / 2)); }
.personal-read-only-notice { display: flex; align-items: center; justify-content: center; gap: 4px; width: fit-content; min-height: 36px; margin-inline: auto; color: var(--pw-muted); font-size: var(--text-body); line-height: var(--leading-body); }
.personal-read-only-notice strong { font-weight: 500; white-space: nowrap; }
-.personal-manager-conversation-tray { display: grid; width: 100%; gap: 9px; max-height: 320px; padding: 12px 14px; margin-bottom: 10px; overflow: hidden; border: 1px solid #bfcdf1; border-radius: 14px; background: rgb(255 255 255 / 97%); box-shadow: 0 5px 20px rgb(34 62 120 / 10%); color: inherit; font: inherit; text-align: left; animation: pw-tray-in 0.16s cubic-bezier(0.16, 1, 0.3, 1); }
-@keyframes pw-tray-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
-.personal-manager-conversation-tray:hover { border-color: #8fa9ee; box-shadow: 0 7px 24px rgb(34 62 120 / 14%); }
-.personal-manager-conversation-tray:focus-within { border-color: var(--pw-blue); box-shadow: 0 0 0 3px rgb(47 102 233 / 13%), 0 5px 20px rgb(34 62 120 / 10%); }
-.personal-manager-conversation-tray > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
-.personal-manager-conversation-tray > header > span { display: flex; align-items: center; gap: 7px; color: var(--pw-blue-ink); }
-.personal-manager-conversation-tray > header strong { font-size: var(--text-lead); font-weight: 600; line-height: var(--leading-lead); }
-.personal-manager-conversation-tray > header small { color: var(--pw-faint); font-size: var(--text-caption); font-weight: 500; line-height: var(--leading-caption); }
-.personal-manager-conversation-actions { display: flex; align-items: center; gap: 8px; }
-.personal-manager-conversation-btn { display: inline-flex; align-items: center; gap: 5px; min-height: 32px; padding: 5px 10px; border: 1px solid #b9c9ee; border-radius: 8px; background: #f4f7fe; color: var(--pw-blue-ink); font-size: var(--text-caption); font-weight: 600; line-height: var(--leading-caption); cursor: pointer; transition: all .12s ease; }
-.personal-manager-conversation-btn:hover { background: var(--pw-blue-soft); border-color: var(--pw-blue); }
-.personal-manager-conversation-link { display: inline-flex; align-items: center; min-height: 32px; padding: 5px 8px; border: 0; border-radius: 7px; background: transparent; color: var(--pw-blue); font-size: var(--text-body); font-weight: 500; line-height: var(--leading-body); cursor: pointer; transition: background .12s ease; }
-.personal-manager-conversation-link:hover { background: var(--pw-blue-soft); }
-.personal-manager-conversation-close { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--pw-faint); cursor: pointer; transition: all .12s ease; }
-.personal-manager-conversation-close:hover { background: #fee2e2; color: #ef4444; }
-.personal-manager-conversation-messages { display: grid; gap: 7px; overflow-y: auto; max-height: 220px; }
-.personal-manager-conversation-messages article { display: grid; grid-template-columns: 86px minmax(0, 1fr); gap: 10px; padding: 10px 12px; border-radius: 10px; background: #f5f7fc; font-size: var(--text-body); line-height: var(--leading-body); }
-.personal-manager-conversation-messages article.is-user { background: #edf3ff; }
-.personal-manager-conversation-messages article strong { color: var(--pw-muted); font-size: var(--text-caption); font-weight: 500; line-height: var(--leading-caption); }
-.personal-manager-conversation-bubble { min-width: 0; }
-.personal-manager-conversation-bubble p { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
-.personal-manager-conversation-bubble .personal-md { font-size: var(--text-body); line-height: var(--leading-prose); }
-.personal-manager-conversation-bubble small { display: inline-block; margin-top: 4px; color: var(--pw-blue); font-size: var(--text-caption); line-height: var(--leading-caption); }
.personal-goal-suggestions { display: flex; gap: 8px; margin-bottom: 9px; flex-wrap: wrap; }
.personal-goal-suggestions button { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 5px 12px; border: 1px solid var(--pw-blue-soft); border-radius: 99px; background: var(--pw-blue-soft); color: var(--pw-blue-ink); font-size: var(--text-body); line-height: var(--leading-body); cursor: pointer; transition: all .14s ease; }
.personal-goal-suggestions button:hover { border-color: var(--pw-blue); }
@@ -1594,9 +1572,6 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
.personal-task-lane-scroll, .personal-task-lane-scroll.has-overflow-before, .personal-task-lane-scroll.has-overflow-after { flex: none; min-height: auto; padding-right: 8px; overflow: visible; box-shadow: none; scrollbar-gutter: auto; }
.personal-quick-prompts { overflow-x: auto; flex-wrap: nowrap; }
.personal-quick-prompts button { flex: 0 0 auto; min-height: 36px; }
- .personal-manager-conversation-tray > header { align-items: flex-start; flex-wrap: wrap; }
- .personal-manager-conversation-actions { width: 100%; justify-content: flex-end; }
- .personal-manager-conversation-messages article { grid-template-columns: minmax(0, 1fr); gap: 4px; }
.personal-timeline-row, .personal-output-row { grid-template-columns: 34px minmax(0, 1fr) 14px; gap: 9px; padding: 10px 12px; }
.personal-timeline-row > time, .personal-timeline-row > .personal-row-status { display: none; }
.personal-timeline-row .personal-priority-dot { display: none; }
@@ -1667,7 +1642,7 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
.personal-goal-lifecycle.is-pending svg { animation: none; }
.personal-goal-mark.is-live::before { animation: none; background: linear-gradient(135deg, #007cf0, #00dfd8); }
.personal-goal-activity-chip.is-live .personal-goal-activity-dot::after { animation: none; }
- .personal-spin { animation: none; }
+ .personal-spin, .personal-chat-activity .is-spinning { animation: none; }
}
/* ===== LoopX standard: docs/development/design.md product tokens ===== */
@@ -1957,15 +1932,6 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
.personal-workspace-shell[data-pw-theme="brutal"] .personal-system-health-header { color: #141414; }
.personal-workspace-shell[data-pw-theme="brutal"] .personal-system-health-header small { color: #555; }
.personal-workspace-shell[data-pw-theme="brutal"] .personal-system-health-issues { color: #141414; }
-.personal-workspace-shell[data-pw-theme="brutal"] .personal-manager-conversation-tray { border: 2px solid #141414; border-radius: 6px; background: #fff; box-shadow: 4px 4px 0 #141414; }
-.personal-workspace-shell[data-pw-theme="brutal"] .personal-manager-conversation-tray:hover { border-color: #141414; box-shadow: 5px 5px 0 #141414; }
-.personal-workspace-shell[data-pw-theme="brutal"] .personal-manager-conversation-tray:focus-within { border-color: #141414; box-shadow: 5px 5px 0 #141414; }
-.personal-workspace-shell[data-pw-theme="brutal"] .personal-manager-conversation-btn { border: 2px solid #141414; border-radius: 4px; background: #ffe23f; color: #141414; box-shadow: 2px 2px 0 #141414; }
-.personal-workspace-shell[data-pw-theme="brutal"] .personal-manager-conversation-btn:hover { background: #ffd91a; }
-.personal-workspace-shell[data-pw-theme="brutal"] .personal-manager-conversation-link { color: #141414; text-decoration: underline; }
-.personal-workspace-shell[data-pw-theme="brutal"] .personal-manager-conversation-close { border: 1.5px solid #141414; border-radius: 4px; color: #141414; }
-.personal-workspace-shell[data-pw-theme="brutal"] .personal-manager-conversation-messages article { border: 1.5px solid #141414; border-radius: 4px; background: #fdf8e7; }
-.personal-workspace-shell[data-pw-theme="brutal"] .personal-manager-conversation-messages article.is-user { background: #8fdcff; }
.personal-workspace-shell[data-pw-theme="brutal"] .personal-quick-prompts button { border: 2px solid #141414; background: #fff; color: #141414; box-shadow: 2px 2px 0 #141414; }
.personal-workspace-shell[data-pw-theme="brutal"] .personal-quick-prompts button:hover { background: #ffe23f; border-color: #141414; color: #141414; }
.personal-workspace-shell[data-pw-theme="brutal"] .personal-first-goal-create { border: 2px solid #141414; border-radius: 4px; background: #ffe23f; color: #141414; box-shadow: 3px 3px 0 #141414; }
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/todo-completion-feedback.ts b/apps/presentation/dashboard/src/features/personal-workspace/todo-completion-feedback.ts
index e2d2157ecc..925a54c75b 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/todo-completion-feedback.ts
+++ b/apps/presentation/dashboard/src/features/personal-workspace/todo-completion-feedback.ts
@@ -1,6 +1,6 @@
import { ChatApiError } from "../../data/chat";
import type { WorkspaceTranslate } from "./i18n";
-import type { WorkspaceActionPreviewRequest } from "./personal-workspace-model";
+import type { WorkspaceActionPreview, WorkspaceActionPreviewRequest } from "./personal-workspace-model";
/** Input shape only; current execution authority is always checked by the backend. */
export function parseTodoCompletionProof(text: string): {
@@ -36,3 +36,13 @@ export function todoCompletionPreviewFeedback(
|| !todoCompletionProofRequired(error)) return null;
return t("feedback.taskCompletionUnavailable");
}
+
+/** Call only after the action owner confirms completion of this exact proposal. */
+export function completedActionFeedback(
+ proposal: Pick
,
+ t: WorkspaceTranslate,
+): string {
+ return proposal.actionKind === "todo.update" && proposal.todoOperation === "complete"
+ ? t("feedback.todoMarkedComplete")
+ : t("feedback.completed", { title: proposal.title });
+}
diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx
index c7235bc23b..7409796e97 100644
--- a/apps/presentation/dashboard/src/views/dashboard-page.tsx
+++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx
@@ -1526,7 +1526,7 @@ function PersonalGoalHome({
const pendingTurnAdmissions = useRef(new Map());
const preparationControllers = useRef(new Map());
const interruptedTurnIds = useRef(new Set());
- const recoveringTurnKeys = useRef(new Map());
+ const turnObservers = useRef(new Map());
// A running Turn a 409 reported, keyed by context: its pending reply holds
// the composer closed until the recovery effect adopts it or an
// authoritative Session read finds no such Turn, so the handoff never leaves
@@ -1833,6 +1833,8 @@ function PersonalGoalHome({
setManagerRuntime(created.session.manager_runtime);
}
recordSessionAdmission(targetContextId, created.session);
+ const observedSessionId = targetContext.kind === "bound" || targetContext.kind === "managed"
+ ? targetContext.sessionId : sessionIds.current.get(sessionKey);
sessionIds.current.set(sessionKey, created.session_id);
const activeSnapshot = history.snapshots.find(
(snapshot) => snapshot.session.session_id === created.session_id,
@@ -1848,19 +1850,31 @@ function PersonalGoalHome({
newSessionRequired.current.delete(sessionKey);
if (!activeTurnId) return;
// A source index can observe the Turn this very composer is already
- // streaming. Exact contexts already key ownership by their Session;
- // keep that live observer instead of replacing its controls and reply.
+ // streaming. Preserve its observer in every conversation kind, using
+ // the Session known before this read replaced the cached admission.
// The history may discover our native Turn before the original ACK
// supplies its id. Its in-flight admission still owns observation.
- if ((targetContext.kind === "bound" || targetContext.kind === "managed")
- && targetContext.sessionId === created.session_id
- && (pendingTurnAdmissions.current.get(targetContextId)?.signal.aborted === false
- || (activeTurnIds.current.get(targetContextId) === activeTurnId
- && streamControllers.current.get(targetContextId)?.signal.aborted === false))) return;
+ if (observedSessionId === created.session_id
+ && pendingTurnAdmissions.current.get(targetContextId)?.signal.aborted === false) return;
const recoveryKey = `${created.session_id}:${activeTurnId}`;
- if (recoveringTurnKeys.current.has(recoveryKey)) return;
+ const existingObserver = turnObservers.current.get(recoveryKey);
+ if (existingObserver && !existingObserver.signal.aborted) {
+ // The Session may confirm a queued Turn started before its stream
+ // reports that start. Adopt its controls and row, not another stream.
+ activeTurnIds.current.set(targetContextId, activeTurnId);
+ streamControllers.current.set(targetContextId, existingObserver);
+ setMessagesByContext(current => {
+ const rows = current[targetContextId] ?? [];
+ if (!rows.some(row => row.role === "assistant" && row.queued
+ && row.sourceSessionId === created.session_id && row.sourceTurnId === activeTurnId)) return current;
+ return {...current, [targetContextId]: rows.map(row => row.role === "assistant" && row.queued
+ && row.sourceSessionId === created.session_id && row.sourceTurnId === activeTurnId
+ ? {...row, queued: false, pending: true, endedAt: undefined, startedAt: Date.now()} : row)};
+ });
+ return;
+ }
const observer = new AbortController();
- recoveringTurnKeys.current.set(recoveryKey, observer);
+ turnObservers.current.set(recoveryKey, observer);
activeTurnIds.current.set(targetContextId, activeTurnId);
recordRuntimeBinding(targetContextId, {
agentId: selectedAgent.agentId,
@@ -1889,8 +1903,8 @@ function PersonalGoalHome({
text: "",
});
const releaseObservation = () => {
- if (recoveringTurnKeys.current.get(recoveryKey) === observer) {
- recoveringTurnKeys.current.delete(recoveryKey);
+ if (turnObservers.current.get(recoveryKey) === observer) {
+ turnObservers.current.delete(recoveryKey);
}
// A late completion may still project its draft after navigation.
// It no longer owns the display or controls of a replacement observer.
@@ -2454,6 +2468,9 @@ function PersonalGoalHome({
}));
},
onPhase: (phase: string, turnId: string) => {
+ // Queued admission also owns an observer. A later native start may
+ // precede the Session read that confirms which Turn owns controls.
+ if (streamController) turnObservers.current.set(`${sessionId}:${turnId}`, streamController);
if (pendingTurnAdmissions.current.get(targetContextId) === streamController) pendingTurnAdmissions.current.delete(targetContextId);
if (submittedTurnId !== turnId) updateConversationMessage(targetContextId, userMessageId, {
sourceTurnId: turnId, sourceSessionId: sessionId,
@@ -2693,6 +2710,8 @@ function PersonalGoalHome({
&& payloadError.turn_replay_safe === true)) throw error;
} finally {
streamEnded = true;
+ const observationKey = `${submittedSessionId}:${submittedTurnId}`;
+ if (turnObservers.current.get(observationKey) === streamController) turnObservers.current.delete(observationKey);
if (pendingTurnAdmissions.current.get(targetContextId) === streamController) pendingTurnAdmissions.current.delete(targetContextId);
// A handed-off Turn is still running: its ownership stays for the
// recovery that adopts it or the read that finds it ended.
@@ -3030,10 +3049,19 @@ function PersonalGoalHome({
},
timeline: workspaceTimeline,
};
+ // Navigation may leave an Agent's exact Session while its existing stream
+ // continues. Project that observed Turn within its Goal, without another read.
+ const observedConversationRunning = Object.entries(runtimeBindings).some(([key, binding]) => {
+ if (binding.status !== "running" || !binding.turnId) return false;
+ const target = conversationContextOfKey(key);
+ return selectedGoal ? (target.kind === "goal" || target.kind === "managed") && target.goalId === selectedGoal.goalId
+ : key === contextId;
+ });
return (
span").innerText(), "已标记完成",
+ "Completion confirms the record change without repeating its task or claiming external execution");
+ }
await page.getByRole("button", { name: "查看更新后的 Goal" }).click();
assert.equal((await read(actionId)).status, "done");
assert.equal((await read(targetId)).status, operation === "complete" ? "open" : "blocked");
diff --git a/examples/personal-workspace-browser/chat-recovery.mjs b/examples/personal-workspace-browser/chat-recovery.mjs
index d0f721bbb0..5f2e3e1d4b 100644
--- a/examples/personal-workspace-browser/chat-recovery.mjs
+++ b/examples/personal-workspace-browser/chat-recovery.mjs
@@ -8,6 +8,18 @@ export const chatRecoveryScenario = {
async run({ browser, collectCoverage, url }) {
const context = await openWorkspacePage(browser, url, { collectCoverage });
const { api, checkpointCoverage, page } = context;
+ // An overview intentionally renders no pending reply. Wait on the native
+ // Session/message owner before sending the next ordinary question.
+ const backgroundReply = async prefix => {
+ const deadline = Date.now() + 10000;
+ while (Date.now() < deadline) {
+ const turn = api.turnRequests.findLast(row => row.message.includes(prefix));
+ if (turn && !page.__loopxRuntime.sessions.get(turn.sessionId)?.active_turn_id
+ && page.__loopxRuntime.messages.get(turn.sessionId)?.some(row => row.turn_id === turn.turnId && row.role !== "user")) return;
+ await page.waitForTimeout(50);
+ }
+ throw new Error(`Background reply did not complete: ${prefix}`);
+ };
const failures = [];
const notes = [];
const observations = [];
@@ -50,30 +62,35 @@ export const chatRecoveryScenario = {
await new Promise((resolveWait) => setTimeout(resolveWait, 50));
}
if (!api.turnRequests.some((turn) => turn.message.includes("汇总所有活跃 Goal 的最新进展与阻塞"))) throw new Error("Progress report shortcut did not send a useful scoped request");
+ await backgroundReply("汇总所有活跃 Goal");
await page.locator(".personal-message-pending").waitFor({ state: "hidden" });
- await page.locator(".personal-manager-conversation-tray").waitFor({ state: "visible" });
+ if (await page.locator(".personal-manager-conversation-tray").count()) throw new Error("A reply covered the manager overview");
if (!(await page.locator(".personal-home-lanes").isVisible())) throw new Error("Manager send replaced the home lane overview");
const managerUrlBefore = page.url();
// This is a fresh question, not an instruction to the shortcut's active Turn.
await page.getByLabel("向 LoopX 发送消息").fill("我现在该做什么?只读回答,不要创建或修改任何状态。");
await page.getByRole("button", { name: "发送", exact: true }).click();
+ await backgroundReply("我现在该做什么?");
+ if (page.url() !== managerUrlBefore) throw new Error("A background reply navigated away from the overview");
+ if (!(await page.locator(".personal-home-lanes").isVisible())) throw new Error("A background reply hid the overview");
+ if (await page.locator(".personal-manager-conversation-tray").count()) throw new Error("A reply reopened a floating conversation");
+ await page.screenshot({path: resolve(outputDir, "manager-overview-after-reply.png"), animations: "disabled"});
+ await managerNavigation.getByRole("button", {name: "对话", exact: true}).click();
await page.getByText("管家已读取当前授权范围的 Goal 证据。", { exact: true }).waitFor({ state: "visible" });
// The steward answers as the LoopX Manager. The executor that served the
// turn belongs to the machine-capability chip, so an answer must never be
// labelled with the CLI brand the agent picker happens to hold.
const answerIdentity = (
- await page.locator(".personal-manager-conversation-tray article.is-assistant strong").last().innerText()
+ await page.locator(".personal-channel-timeline article.is-assistant header strong").last().innerText()
).trim();
if (answerIdentity !== "LoopX 管家") {
throw new Error(`Manager answer was labelled as its executor instead of the steward: ${answerIdentity}`);
}
if (!api.turnRequests.some((turn) => turn.message.startsWith("我现在该做什么?"))) throw new Error("Manager question bypassed the global runtime");
- await page.getByText("查看完整对话", { exact: true }).waitFor({ state: "visible" });
- if (page.url() !== managerUrlBefore) throw new Error(`Manager send navigated away from the overview: ${managerUrlBefore} -> ${page.url()}`);
- const managerConversationType = await page.locator(".personal-manager-conversation-tray").evaluate((tray) => {
- const message = getComputedStyle(tray.querySelector("article p, article .personal-md"));
- const role = getComputedStyle(tray.querySelector("article > strong"));
- const action = getComputedStyle(tray.querySelector(".personal-manager-conversation-link"));
+ const managerConversationType = await page.locator(".personal-channel-timeline").evaluate((timeline) => {
+ const message = getComputedStyle(timeline.querySelector("article.is-assistant p, article.is-assistant .personal-md"));
+ const role = getComputedStyle(timeline.querySelector("article.is-assistant header strong"));
+ const action = getComputedStyle(document.querySelector(".personal-goal-tabs button"));
return {
actionFontSize: Number.parseFloat(action.fontSize),
messageFontSize: Number.parseFloat(message.fontSize),
@@ -85,15 +102,14 @@ export const chatRecoveryScenario = {
|| managerConversationType.messageLineHeight < 20
|| managerConversationType.roleFontSize < 12
|| managerConversationType.actionFontSize < 14) {
- throw new Error(`Manager conversation receipt typography is below the readable UI scale: ${JSON.stringify(managerConversationType)}`);
+ throw new Error(`Manager conversation typography is below the readable UI scale: ${JSON.stringify(managerConversationType)}`);
}
- await page.screenshot({ path: resolve(outputDir, "manager-conversation-tray-compact.png"), fullPage: false, animations: "disabled" });
+ await page.screenshot({ path: resolve(outputDir, "manager-conversation-reading-desktop.png"), fullPage: false, animations: "disabled" });
await page.setViewportSize({ width: 390, height: 844 });
- const compactTrayOverflow = await page.locator(".personal-manager-conversation-tray").evaluate((tray) => tray.scrollWidth - tray.clientWidth);
- if (compactTrayOverflow > 1) throw new Error(`Manager conversation receipt has ${compactTrayOverflow}px horizontal overflow at mobile width`);
- await page.screenshot({ path: resolve(outputDir, "manager-conversation-tray-mobile.png"), fullPage: false, animations: "disabled" });
+ const conversationOverflow = await page.locator(".personal-channel-timeline").evaluate((timeline) => timeline.scrollWidth - timeline.clientWidth);
+ if (conversationOverflow > 1) throw new Error(`Manager conversation has ${conversationOverflow}px horizontal overflow at mobile width`);
+ await page.screenshot({ path: resolve(outputDir, "manager-conversation-reading-mobile.png"), fullPage: false, animations: "disabled" });
await page.setViewportSize({ width: 1512, height: 982 });
- await page.getByText("查看完整对话", { exact: true }).click();
await page.getByRole("navigation", { name: "管家视图" }).waitFor({ state: "visible" });
if (await page.locator(".personal-home-board").isVisible()) throw new Error("Full manager Chat left the Goal overview visible behind the conversation");
if (await page.locator(".personal-manager-conversation-tray").count()) throw new Error("Full manager Chat kept the compact home tray visible");
@@ -224,7 +240,7 @@ export const chatRecoveryScenario = {
await page.getByRole("button", { name: "总览", exact: true }).click();
await page.locator(".personal-home-board").waitFor({ state: "visible" });
if (await page.locator(".personal-manager-conversation-tray").count()) {
- throw new Error("Manager conversation receipt stayed permanently visible after returning to the overview");
+ throw new Error("Manager conversation stayed permanently visible after returning to the overview");
}
await page.locator(".personal-composer-tools > summary").click();
diff --git a/examples/personal-workspace-browser/chat-todo-proposal.mjs b/examples/personal-workspace-browser/chat-todo-proposal.mjs
index 401be96ce1..235ec9bfd2 100644
--- a/examples/personal-workspace-browser/chat-todo-proposal.mjs
+++ b/examples/personal-workspace-browser/chat-todo-proposal.mjs
@@ -63,11 +63,21 @@ export const chatTodoProposalScenario = {
&& preview.normalized_parameters?.text === text);
const todoPreviews = () => previewsWithText(PROPOSAL_TEXT);
const composer = page.getByLabel("向 LoopX 发送消息");
+ const openManagerChat = async () => {
+ await page.locator(".personal-manager-link").first().click();
+ const managerChatTab = page.getByRole("navigation", { name: /Manager|管家/ }).getByRole("button", { name: /^(Chat|对话)$/ });
+ await managerChatTab.click();
+ if (await managerChatTab.getAttribute("aria-current") !== "page") throw new Error("Manager Chat did not open");
+ };
try {
// Manager channel: no target Goal, so the proposal cannot become a write.
await composer.fill(MANAGER_PROMPT);
await page.getByRole("button", { name: "发送", exact: true }).click();
+ await waitFor(() => api.turnRequests.length === 1, "The unscoped manager Turn was not admitted");
+ if (await page.locator(".personal-manager-conversation-tray").count()) throw new Error("A proposal reply covered the manager overview");
+ await openManagerChat();
await page.getByText("我找到一个可评审的步骤。", { exact: true }).first().waitFor({ state: "visible", timeout: 10_000 });
+ if (api.turnRequests.length !== 1) throw new Error("Opening Chat sent the manager request again");
await page.waitForTimeout(500);
if (todoPreviews().length) throw new Error("A manager-channel proposal created a Todo preview without a target Goal");
@@ -75,12 +85,6 @@ export const chatTodoProposalScenario = {
await page.locator(".personal-goal-link", { hasText: "Product Release" }).click();
await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click();
};
- const openManagerChat = async () => {
- await page.locator(".personal-manager-link").first().click();
- const managerChatTab = page.getByRole("navigation", { name: /Manager|管家/ }).getByRole("button", { name: /^(Chat|对话)$/ });
- await managerChatTab.click();
- if (await managerChatTab.getAttribute("aria-current") !== "page") throw new Error("Manager Chat did not open");
- };
// Explicit manager targets reach the existing confirmation card in place;
// this is a preview, never an implicit apply or a new execution.
await openManagerChat();
diff --git a/examples/personal-workspace-browser/conversation-activity.mjs b/examples/personal-workspace-browser/conversation-activity.mjs
index 7750f8a250..2eba84897f 100644
--- a/examples/personal-workspace-browser/conversation-activity.mjs
+++ b/examples/personal-workspace-browser/conversation-activity.mjs
@@ -10,9 +10,12 @@ export const conversationActivityScenario = {
id: "conversation-activity",
async run({ browser, collectCoverage, url }) {
const streams = new Map();
+ const observations = [];
const event = (sequence, kind, payload) => `id: ${sequence}\nevent: ${kind}\ndata: ${JSON.stringify({ event_id: String(sequence), sequence, kind, payload })}\n\n`;
const server = createServer((request, response) => {
+ observations.push(request.url);
response.writeHead(200, { "Content-Type": "text/event-stream", "Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache" });
+ response.write(event(0, "turn.started", {}));
response.write(event(1, "agent.phase", { label: "Agent 正在执行命令" }));
response.write(event(2, "agent.phase", { label: "Agent 正在检索" }));
response.write(event(3, "agent.phase", { label: "Agent 正在执行命令" }));
@@ -41,6 +44,7 @@ export const conversationActivityScenario = {
};
assert.equal(await page.getByRole("button", { name: "恢复未确认的追加指令", exact: true }).count(), 0, "A new conversation has no recovery control");
const turn = await send("请检查当前任务状态,并说明下一步。");
+ await page.getByRole("status", {name: "正在回复", exact: true}).waitFor();
const pending = page.locator(".personal-message").filter({ has: page.getByRole("button", { name: "中断本轮", exact: true }) });
await page.clock.install();
await page.clock.fastForward(25000);
@@ -51,11 +55,10 @@ export const conversationActivityScenario = {
const elapsedBefore = await pending.locator(".personal-message-elapsed").textContent();
await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "总览", exact: true }).click();
const tray = page.locator(".personal-manager-conversation-tray");
- await tray.getByRole("button", { name: "中断本轮", exact: true }).waitFor();
- assert.equal(await tray.locator(".personal-message-elapsed").textContent(), elapsedBefore, "view switch keeps request elapsed time");
- await tray.locator(".personal-message-quiet").waitFor();
await page.screenshot({ path: resolve(outputDir, "conversation-waiting-overview.png"), animations: "disabled" });
- await tray.getByRole("button", { name: "查看完整对话", exact: true }).click();
+ assert.equal(await tray.count(), 0, "Background updates never cover the overview with a conversation");
+ assert.equal(await page.getByRole("status", {name: "正在回复", exact: true}).count(), 1, "Activity stays on the Chat entry");
+ await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "对话", exact: true }).click();
assert.equal(await pending.locator(".personal-message-elapsed").textContent(), elapsedBefore);
await page.clock.resume();
const adjustments = [];
@@ -105,10 +108,10 @@ export const conversationActivityScenario = {
assert.equal(await composer.inputValue(), "先核对依赖,再继续当前任务。");
await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "总览", exact: true }).click();
assert.equal(await composer.inputValue(), "先核对依赖,再继续当前任务。", "The same draft survives switching to the overview");
- await tray.getByRole("button", { name: "调整本轮", exact: true }).click();
- assert.equal(await composer.evaluate(node => node === document.activeElement), true, "Overview activity focuses the same input");
- assert.equal(await tray.locator("textarea").count(), 0);
- await tray.getByRole("button", { name: "查看完整对话", exact: true }).click();
+ assert.equal(await tray.count(), 0, "An uncertain steering receipt cannot recreate a floating conversation");
+ await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "对话", exact: true }).click();
+ await pending.getByRole("button", { name: "调整本轮", exact: true }).click();
+ assert.equal(await composer.evaluate(node => node === document.activeElement), true, "Chat activity focuses the same input");
await submit.click();
await feedback.filter({ hasText: "回执不匹配" }).waitFor();
await page.reload({ waitUntil: "domcontentloaded" });
@@ -198,6 +201,7 @@ export const conversationActivityScenario = {
await pending.getByRole("button", { name: "中断本轮", exact: true }).click();
await page.getByText("已中断。你可以在当前会话继续发送消息。", { exact: true }).waitFor();
assert.equal(await page.locator(".personal-md").filter({ hasText: "已找到两个待处理项" }).count(), 1, "interruption preserves partial output");
+ await page.locator(".personal-chat-activity").waitFor({state: "hidden"});
assert.deepEqual(api.interrupts.at(-1), { sessionId: turn.sessionId, turnId: turn.turnId });
await page.screenshot({ path: resolve(outputDir, "conversation-activity-interrupted.png"), animations: "disabled" });
await page.unroute("**/interrupt", rejectThenMismatch);
@@ -218,6 +222,7 @@ export const conversationActivityScenario = {
});
await page.getByRole("button", { name: "中断本轮", exact: true }).click();
await page.getByText("检查完成,下一步已列出。", { exact: true }).waitFor();
+ await page.locator(".personal-chat-activity").waitFor({state: "hidden"});
assert.equal(await page.getByRole("button", { name: "中断本轮", exact: true }).count(), 0);
assert.equal(await composer.inputValue(), "保留我的未发送草稿。");
assert.equal(await submit.isEnabled(), true, "An unsent draft becomes an explicit follow-up after completion");
@@ -230,7 +235,66 @@ export const conversationActivityScenario = {
// The same interaction is present in Goal Chat, through the same timeline.
await page.locator(".personal-goal-link").first().click();
await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click();
+ // Native ordinary sends can be acknowledged as queued before starting.
+ // Their original observer must survive the asynchronous current-Turn read.
+ let pendingActiveRead;
+ await page.route("**/api/chat/sessions/*/turns", async route => {
+ const body = route.request().postDataJSON();
+ if (body.message !== "请检查这个 Goal 的当前状态。") return route.fallback();
+ const sessionId = new URL(route.request().url()).pathname.split("/").at(-2);
+ const turnId = `queued-start-${Date.now()}`;
+ const runtime = page.__loopxRuntime;
+ runtime.turnMessages.set(turnId, body.message);
+ runtime.sessions.set(sessionId, {...runtime.sessions.get(sessionId), active_turn_id: turnId, status: "busy"});
+ runtime.messages.get(sessionId).push({message_id: `${turnId}-user`, turn_id: turnId, role: "user", text: body.message});
+ api.turnRequests.push({message: body.message, sessionId, turnId});
+ let holdFirstRead = true;
+ await page.route(`**/api/chat/sessions/${sessionId}`, snapshot => {
+ if (holdFirstRead) { holdFirstRead = false; pendingActiveRead = snapshot; return; }
+ return snapshot.fallback();
+ });
+ await route.fulfill({status: 202, json: {ok: true, session_id: sessionId, turn_id: turnId,
+ created: true, status: "queued", events_url: `/events/${sessionId}/${turnId}`}});
+ });
const goalTurn = await send("请检查这个 Goal 的当前状态。");
+ const goalTabs = page.getByRole("navigation", {name: "Goal 视图"});
+ for (const width of [1512, 390]) {
+ await page.setViewportSize({width, height: width === 390 ? 844 : 982});
+ for (const tab of ["概览", "任务", "研究", "成果"]) {
+ await goalTabs.getByRole("button", {name: tab, exact: true}).click();
+ assert.equal(await tray.count(), 0, `${tab} stays clear of background conversations`);
+ assert.equal(await page.getByRole("status", {name: "正在回复", exact: true}).count(), 1);
+ assert.equal(api.turnRequests.length, 3, "Navigation neither restarts nor cancels work");
+ await page.screenshot({path: resolve(outputDir, `conversation-background-${tab}-${width}.png`), animations: "disabled"});
+ }
+ }
+ await page.setViewportSize({width: 1512, height: 982});
+ await page.locator(".personal-goal-link").nth(1).click();
+ await page.locator(".personal-chat-activity").waitFor({state: "hidden"});
+ assert.equal(api.interrupts.length, 1, "Changing Goals does not interrupt the active conversation");
+ await page.locator(".personal-goal-link").first().click();
+ await page.getByRole("status", {name: "正在回复", exact: true}).waitFor();
+ await page.emulateMedia({reducedMotion: "reduce"});
+ assert.equal(await page.locator(".personal-chat-activity svg").evaluate(node => getComputedStyle(node).animationName), "none");
+ await page.emulateMedia({reducedMotion: "no-preference"});
+ await page.setViewportSize({width: 1512, height: 982});
+ await goalTabs.getByRole("button", {name: "对话", exact: true}).focus();
+ const resumedSession = page.waitForResponse(response => new URL(response.url()).pathname === "/api/chat/sessions"
+ && response.request().method() === "POST");
+ await page.keyboard.press("Enter");
+ await resumedSession;
+ await page.evaluate(() => new Promise(resolveFrame => requestAnimationFrame(() => requestAnimationFrame(resolveFrame))));
+ assert.equal(await goalTabs.getByRole("button", {name: "对话", exact: true}).getAttribute("aria-current"), "page");
+ await page.screenshot({path: resolve(outputDir, "conversation-observer-desktop.png"), animations: "disabled"});
+ await page.setViewportSize({width: 390, height: 844});
+ await page.screenshot({path: resolve(outputDir, "conversation-observer-narrow.png"), animations: "disabled"});
+ await page.setViewportSize({width: 1512, height: 982});
+ assert.equal(observations.filter(path => path === `/events/${goalTurn.sessionId}/${goalTurn.turnId}`).length, 1,
+ "Returning to Goal Chat preserves the original live observer instead of attaching a recovery stream");
+ assert.equal(await page.getByRole("button", {name: "调整本轮", exact: true}).count(), 1,
+ "The same running Session/Turn has one set of controls after navigation");
+ assert.ok(pendingActiveRead, "The queued-start confirmation remains outstanding during navigation");
+ await pendingActiveRead.fallback();
await page.getByRole("button", { name: "调整本轮", exact: true }).click();
assert.equal(await composer.inputValue(), "", "another conversation never inherits the old draft");
await page.evaluate(({sessionId, turnId}) => {
@@ -280,7 +344,17 @@ export const conversationActivityScenario = {
await page.evaluate(() => { delete window.sessionStorage.setItem; });
await page.getByRole("button", { name: "中断本轮", exact: true }).click();
await page.getByText("已中断。你可以在当前会话继续发送消息。", { exact: true }).waitFor();
+ await page.locator(".personal-chat-activity").waitFor({state: "hidden"});
assert.deepEqual(api.interrupts.at(-1), { sessionId: goalTurn.sessionId, turnId: goalTurn.turnId });
+ page.__loopxRuntime.queuedTurns.set(goalTurn.sessionId, [{
+ turn_id: "waiting-for-host", created_at: "2026-08-13T01:00:01Z", origin: "web",
+ preview: "等待执行器接收的后续消息", character_count: 13, attachment_count: 0, steering: null,
+ }]);
+ await page.reload({waitUntil: "domcontentloaded"});
+ await page.getByRole("button", {name: "对话", exact: true}).click();
+ await page.getByRole("region", {name: "待处理消息", exact: true}).waitFor();
+ assert.equal(await page.locator(".personal-chat-activity").count(), 0, "A queued message alone is not active execution");
+ assert.equal(api.turnRequests.length, 3, "Queue readback does not start work");
return { coverageEntries: context.coverageEntries, note: "Shared steward/Goal activity and steering: view/reload recovery without automatic dispatch, one composer, blocked storage, terminal draft isolation, interruption and completion races." };
} finally {
for (const response of streams.values()) response.destroy();
diff --git a/examples/personal-workspace-browser/conversation-startup.mjs b/examples/personal-workspace-browser/conversation-startup.mjs
index c9a0a196ac..e10dc80575 100644
--- a/examples/personal-workspace-browser/conversation-startup.mjs
+++ b/examples/personal-workspace-browser/conversation-startup.mjs
@@ -38,22 +38,25 @@ export const conversationStartupScenario = {
await page.getByRole("button", { name: "发送", exact: true }).click();
};
await send("帮我整理研究目标,先不要执行。");
- const tray = page.locator(".personal-manager-conversation-tray");
- await tray.locator(".personal-message-work-current").getByText("已接收,正在连接执行器", { exact: true }).waitFor();
+ assert.equal(await page.locator(".personal-manager-conversation-tray").count(), 0);
+ assert.equal(await page.locator(".personal-chat-activity").count(), 0, "Preparation is not a running Turn");
+ await page.getByRole("button", {name: "对话", exact: true}).click();
+ const conversation = page.locator(".personal-channel-timeline");
+ await conversation.locator(".personal-message-work-current").getByText("已接收,正在连接执行器", { exact: true }).waitFor();
assert.equal(api.turnRequests.length, 0, "receipt precedes executor connection and turn submission");
- await tray.getByRole("button", { name: "取消发送", exact: true }).click();
- await tray.getByText("已取消发送;请求尚未交给执行器处理。", { exact: true }).waitFor();
+ await conversation.getByRole("button", { name: "取消发送", exact: true }).click();
+ await conversation.getByText("已取消发送;请求尚未交给执行器处理。", { exact: true }).waitFor();
await page.getByLabel("向 LoopX 发送消息").fill("重试整理目标。");
assert.equal(await page.getByRole("button", { name: "发送", exact: true }).isEnabled(), true);
fail = true;
waiting.splice(0).forEach(resolve => resolve());
assert.equal(api.turnRequests.length, 0, "cancelled preparation cannot dispatch a turn");
await send("重试整理目标。");
- await tray.getByText("尚未提交请求。连接执行器失败,可以重新发送。", { exact: false }).waitFor();
- assert.equal(await tray.getByRole("button", { name: "中断本轮", exact: true }).count(), 0, "no invented turn control after startup failure");
- assert.equal(await tray.locator(".personal-message-work-current").count(), 0, "failed preparation stops presenting live progress");
+ await conversation.getByText("尚未提交请求。连接执行器失败,可以重新发送。", { exact: false }).waitFor();
+ assert.equal(await conversation.getByRole("button", { name: "中断本轮", exact: true }).count(), 0, "no invented turn control after startup failure");
+ assert.equal(await conversation.locator(".personal-message-work-current").count(), 0, "failed preparation stops presenting live progress");
assert.equal(api.turnRequests.length, 0);
- return { coverageEntries: context.coverageEntries, note: "Immediate preparation receipt, pre-dispatch cancellation, retry and honest startup failure through the overview conversation." };
+ return { coverageEntries: context.coverageEntries, note: "Immediate preparation receipt, pre-dispatch cancellation, retry and honest startup failure through the explicit Chat entry; preparation never covers the overview or claims to be running." };
} finally {
waiting.splice(0).forEach(resolve => resolve());
await context.close();
diff --git a/examples/personal-workspace-browser/goal-draft.mjs b/examples/personal-workspace-browser/goal-draft.mjs
index 6819fdaefb..69402a06d6 100644
--- a/examples/personal-workspace-browser/goal-draft.mjs
+++ b/examples/personal-workspace-browser/goal-draft.mjs
@@ -11,6 +11,7 @@ export const goalDraftScenario = {
const draft = {objective: "研究微软近三年的现金流", completion_criteria: "有公开来源的数据和可读报告", execution_boundary: "仅使用公开财报", question: "更想了解哪方面?", options: ["现金流趋势", "资本支出变化"]};
api.answerForMessage = (message) => message === "解释一下 Goal 是什么" ? "Goal 保存目标和完成标准。" : {message: "先确认研究重点,再检查目标设置。", goal_draft: {...draft, ...(message.includes("资本支出") ? {completion_criteria: "重点比较资本支出,列出来源", question: "", options: []} : {})}};
try {
+ await page.getByRole("navigation", {name: "管家视图"}).getByRole("button", {name: "对话", exact: true}).click();
const composer = page.getByLabel("向 LoopX 发送消息");
async function send(text) {
await composer.fill(text);
@@ -32,7 +33,6 @@ export const goalDraftScenario = {
assert.equal(api.durableWriteCount, writes);
await send("重点比较资本支出,列出来源");
await card.getByText("重点比较资本支出,列出来源", {exact: true}).waitFor();
- await page.getByRole("button", {name: "查看完整对话", exact: true}).click();
await page.reload({waitUntil: "networkidle"});
await page.getByRole("navigation", {name: "管家视图"}).getByRole("button", {name: /^(Chat|对话)$/}).click();
await card.getByText("重点比较资本支出,列出来源", {exact: true}).waitFor();
diff --git a/examples/personal-workspace-browser/managed-agent-sessions.mjs b/examples/personal-workspace-browser/managed-agent-sessions.mjs
index 83d95f77de..f3ce3a5c3b 100644
--- a/examples/personal-workspace-browser/managed-agent-sessions.mjs
+++ b/examples/personal-workspace-browser/managed-agent-sessions.mjs
@@ -173,9 +173,32 @@ async function verifyManagedTurnObservers({page, origin, sessions, panel, choose
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
await page.screenshot({path: resolve(outputDir, "managed-observer-narrow.png")});
await assertSingleObserver(ownTurn, 1);
+ const managedUrl = page.url();
+ await composer.fill("Retain this original Session's unsent draft across work views.");
+ await page.setViewportSize({width: 1512, height: 982});
+ for (const view of ["概览", "任务", "研究", "成果"]) {
+ await page.getByRole("navigation", {name: "Goal 视图"}).getByRole("button", {name: view, exact: true}).click();
+ await page.getByRole("status", {name: "正在回复", exact: true}).waitFor();
+ assert.equal(await page.locator(".personal-manager-conversation-tray").count(), 0,
+ "An observed managed Turn stays on Chat without covering a reading view");
+ }
+ await page.setViewportSize({width: 390, height: 844});
+ assert.equal(await page.getByRole("status", {name: "正在回复", exact: true}).count(), 1);
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
+ await page.screenshot({path: resolve(outputDir, "managed-observer-background-narrow.png")});
+ await page.setViewportSize({width: 1512, height: 982});
+ await page.locator(".personal-manager-link").click();
+ assert.equal(await page.getByRole("status", {name: "正在回复", exact: true}).count(), 0,
+ "Another conversation never inherits the Goal's managed Turn activity");
+ for (let step = 0; step < 5; step += 1) await page.goBack();
+ await page.waitForURL(managedUrl);
+ await pending.waitFor();
+ assert.equal(await composer.inputValue(), "Retain this original Session's unsent draft across work views.");
+ await assertSingleObserver(ownTurn, 1);
await page.setViewportSize({width: 1440, height: 900});
await verifyQueuedEditor({page, origin, sessionId, turnId: ownTurn, composer, control});
await interrupt(ownTurn);
+ await page.getByRole("status", {name: "正在回复", exact: true}).waitFor({state: "hidden"});
// Start through the same real ingress while another Session is selected.
// Entering the original must recover it without a composer-owned observer.
diff --git a/loopx/control_plane/collaboration/conversation_binding.ts b/loopx/control_plane/collaboration/conversation_binding.ts
index 7c7b302d89..a0ad580c70 100644
--- a/loopx/control_plane/collaboration/conversation_binding.ts
+++ b/loopx/control_plane/collaboration/conversation_binding.ts
@@ -1141,8 +1141,8 @@ function planExhaustedStewardAnswer(recovery: JsonObject, row: JsonObject, curre
&& Number(item.manager_context_version) <= version)
.map(item => sessionIdentity(item.thread_id, "original native thread"))];
const priorNative = budget.native_observation as JsonObject | undefined;
- const nativeThreads = [...new Set([...(typeof priorNative?.thread_id === "string" && threads.includes(priorNative.thread_id)
- ? [priorNative.thread_id] : []), ...threads])];
+ const priorThreadId = priorNative?.thread_id;
+ const nativeThreads = [...new Set([...(typeof priorThreadId === "string" && threads.includes(priorThreadId) ? [priorThreadId] : []), ...threads])];
const candidate = {...no, recovery_candidate: true, answer_review: true, read_only_review: true,
client_turn_id: client, session_id: current.session_id, failed_turn_id: failed.turn_id,
native_thread_ids: nativeThreads};
diff --git a/loopx/status.py b/loopx/status.py
index cc9c56f6c0..f19cd1c2d6 100644
--- a/loopx/status.py
+++ b/loopx/status.py
@@ -228,6 +228,8 @@
attach_dependency_blockers,
compact_todo_group as compact_todo_group,
compact_todo_item as compact_todo_item,
+ open_todo_items as open_todo_items,
+ todo_item_task_class as todo_item_task_class,
first_open_todo_text,
project_asset_todo_summary,
sync_connected_attention_action_from_todos as _sync_connected_attention_action_from_todos_read_model,
@@ -267,6 +269,8 @@
"project_asset_summary_is_public_safe": "loopx.control_plane.work_items.project_asset",
"compact_todo_group": "loopx.control_plane.todos.todo_summary",
"compact_todo_item": "loopx.control_plane.todos.todo_summary",
+ "open_todo_items": "loopx.control_plane.todos.todo_summary",
+ "todo_item_task_class": "loopx.control_plane.todos.todo_summary",
"todo_lane_items": "loopx.control_plane.todos.todo_summary",
"todo_item_is_deferred": "loopx.control_plane.todos.todo_summary",
"todo_item_is_due_monitor": "loopx.control_plane.todos.todo_summary",