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",