Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion apps/presentation/dashboard/smoke/todo-ownership-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -12,6 +12,7 @@ export function ChannelHeader({
agents,
agentWorkActive = false,
conversationScope,
conversationRunning = false,
goalLifecycleBusy,
managerChannelBinding,
managerChatOpen,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -84,6 +86,11 @@ export function ChannelHeader({
}) {
const { locale, t } = useWorkspaceI18n();
const statusOnly = selectedAgentId === "status-only";
const chatLabel = <>{t("header.chat")}{conversationRunning ? (
<span aria-label={t("conversation.replying")} className="personal-chat-activity" role="status">
<LoaderCircle aria-hidden className="is-spinning" size={12} />
</span>
) : 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
Expand Down Expand Up @@ -259,7 +266,7 @@ export function ChannelHeader({
<button aria-current={selectedGoalTab === "overview" ? "page" : undefined} onClick={() => onSelectGoalTab("overview")} type="button">{t("header.overview")}</button>
<button aria-current={selectedGoalTab === "tasks" ? "page" : undefined} onClick={() => onSelectGoalTab("tasks")} type="button">{t("header.tasks")}</button>
<button aria-current={selectedGoalTab === "research" ? "page" : undefined} onClick={() => onSelectGoalTab("research")} type="button">{t("header.research")}</button>
<button aria-current={selectedGoalTab === "chat" ? "page" : undefined} onClick={() => onSelectGoalTab("chat")} type="button">{t("header.chat")}</button>
<button aria-current={selectedGoalTab === "chat" ? "page" : undefined} aria-label={t("header.chat")} onClick={() => onSelectGoalTab("chat")} type="button">{chatLabel}</button>
<button aria-current={selectedGoalTab === "files" ? "page" : undefined} onClick={() => onSelectGoalTab("files")} type="button">{t("header.files")}</button>
</nav>
{researchControl}
Expand All @@ -268,7 +275,7 @@ export function ChannelHeader({
) : !scopeUnavailable ? (
<nav aria-label={t("header.managerView")} className="personal-goal-tabs">
<button aria-current={!managerChatOpen ? "page" : undefined} onClick={onReturnManagerHome} type="button">{t("header.managerOverview")}</button>
<button aria-current={managerChatOpen ? "page" : undefined} onClick={onOpenManagerChat} type="button">{t("header.chat")}</button>
<button aria-current={managerChatOpen ? "page" : undefined} aria-label={t("header.chat")} onClick={onOpenManagerChat} type="button">{chatLabel}</button>
</nav>
) : null}
<div className="personal-channel-actions">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down Expand Up @@ -1920,6 +1921,7 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"feedback.applying": "正在执行:{title}",
"feedback.cancelFailed": "取消失败:{error}",
"feedback.completed": "已完成:{title}",
"feedback.todoMarkedComplete": "已标记完成",
"feedback.executionFailed": "执行失败:{error}",
"feedback.gateRequired": "需要你确认:{summary}",
"feedback.notCompleted": "操作未完成:{status}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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]*<ManagerConversationTray/, "The tray only appears on the manager overview after a send");
assert.match(page, /managerMessages\.some\(\(message\) => 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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading