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
2 changes: 1 addition & 1 deletion apps/presentation/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
"smoke:goal-acceptance-contract-browser": "node smoke/goal-acceptance-contract-browser-smoke.mjs",
"smoke:goal-acceptance-contract-packaged": "LOOPX_ACCEPTANCE_CONTRACT_PACKAGED=1 LOOPX_ACCEPTANCE_CONTRACT_PORT=5297 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-acceptance-contract-browser-smoke.mjs",
"smoke:status-projection-contract": "rm -rf /tmp/loopx-status-projection-contract-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --resolveJsonModule --esModuleInterop --outDir /tmp/loopx-status-projection-contract-smoke smoke/status-projection-contract-smoke.ts src/data/status.ts src/data/status-merge.ts src/data/status-request-fence.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-status-projection-contract-smoke/apps/presentation/dashboard/smoke/status-projection-contract-smoke.js",
"smoke:team-plan-proposal": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --ignoreDeprecations 6.0 --jsx react-jsx --types node --skipLibCheck --strict --rootDir . --outDir node_modules/.cache/loopx-team-plan-smoke smoke/team-plan-proposal-smoke.ts src/data/chat.ts src/features/personal-workspace/team-plan-preview.ts src/vite-env.d.ts && node node_modules/.cache/loopx-team-plan-smoke/smoke/team-plan-proposal-smoke.js",
"smoke:team-plan-proposal": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --ignoreDeprecations 6.0 --jsx react-jsx --types node --skipLibCheck --strict --rootDir ../../.. --outDir node_modules/.cache/loopx-team-plan-smoke smoke/team-plan-proposal-smoke.ts src/data/chat.ts src/features/personal-workspace/team-plan-preview.ts src/vite-env.d.ts && node node_modules/.cache/loopx-team-plan-smoke/apps/presentation/dashboard/smoke/team-plan-proposal-smoke.js",
"smoke:status-source-switch-browser": "node ../../../examples/status-source-switch-browser-smoke.mjs",
"smoke:status-source-switch-packaged": "LOOPX_STATUS_SOURCE_SWITCH_PACKAGED=1 LOOPX_STATUS_SOURCE_SWITCH_PORT=5198 node ../../../examples/status-source-switch-browser-smoke.mjs",
"smoke:status-sources": "rm -rf /tmp/loopx-status-source-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --resolveJsonModule --esModuleInterop --outDir /tmp/loopx-status-source-smoke smoke/status-source-catalog-smoke.ts src/data/ssh-host-catalog.ts src/data/status-source-catalog.ts src/data/local-status-query.ts src/data/status.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-status-source-smoke/apps/presentation/dashboard/smoke/status-source-catalog-smoke.js",
Expand Down
2 changes: 2 additions & 0 deletions apps/presentation/dashboard/src/data/chat-model.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { GoalDraft } from "../../../../../loopx/control_plane/collaboration/goal_draft.js";
export type LoopXModeSettings = { agent_id: string; token_budget: number };

export type ChatTodo = {
Expand Down Expand Up @@ -153,6 +154,7 @@ export function isTeamPlanPreviewProposal(
}

export type AgentResponse = {
goal_draft?: GoalDraft | null;
schema_version: "loopx_chat_agent_response_v0";
message: string;
proposals: AgentProposal[];
Expand Down
3 changes: 3 additions & 0 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { normalizeGoalDraft } from "../../../../../loopx/control_plane/collaboration/goal_draft.js";
import { z } from "zod";

import {
Expand Down Expand Up @@ -225,6 +226,7 @@ export type ProtectedActionProposal = z.infer<typeof protectedActionProposalSche
export const agentResponseSchema = z.object({
schema_version: z.literal("loopx_chat_agent_response_v0"),
message: z.string(),
goal_draft: z.unknown().optional().transform(normalizeGoalDraft),
proposals: z.array(agentProposalSchema),
protected_action: protectedActionProposalSchema.nullable().optional().default(null),
gate: z
Expand Down Expand Up @@ -694,6 +696,7 @@ export type ManagerRuntimeSessionReadback = {
};

export type ChatVisibleMessage = {
goal_draft?: ReturnType<typeof normalizeGoalDraft>;
/** Client-side lineage added when messages from several Sessions are merged. */
session_id?: string;
collaboration?: CollaborationReadback;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { GoalDraftCard } from "./goal-draft-card";
import type { GoalDraft } from "../../../../../../loopx/control_plane/collaboration/goal_draft.js";
import {Fragment, useRef, useState} from "react";
import { ChatApiError } from "../../data/chat.js";
import { CollaborationCard } from "./collaboration-card";
Expand Down Expand Up @@ -104,6 +106,8 @@ export function ChannelTimeline({
onSelect,
selectedGoal,
showManagerTeamResults = false,
onReviewGoalDraft,
onSuggestReply,
onOpenGoalEvidence,
onInterruptTurn,
onSteerTurn,
Expand All @@ -112,6 +116,8 @@ export function ChannelTimeline({
onSelect: (selection: WorkspaceDrawerSelection) => void;
selectedGoal: WorkspaceGoal | null;
showManagerTeamResults?: boolean;
onReviewGoalDraft?: (draft: GoalDraft, edit?: boolean, draftId?: string) => Promise<void>;
onSuggestReply?: (text: string) => void;
onOpenGoalEvidence?: (goalId: string) => void;
onInterruptTurn?: (turnId: string) => Promise<void>;
onSteerTurn?: (turnId: string, text: string, ingressId: string) => Promise<void>;
Expand Down Expand Up @@ -212,6 +218,8 @@ export function ChannelTimeline({
target="_blank" rel="noopener noreferrer">{locale === "zh-CN" ? "单独阅读完整答复" : "Read full answer separately"}</a>
: null}
{item.message.role !== "user" && (item.message.pending || item.message.sourceTurnId || item.message.activity?.length) ? <MessageActivity message={item.message} onInterruptTurn={onInterruptTurn} onSteerTurn={onSteerTurn}/> : null}
{item.message.role === "assistant" && !item.message.pending && item.message.goalDraft
? <GoalDraftCard draftId={`${item.message.sourceSessionId ?? ""}:${item.message.id}`} draft={item.message.goalDraft} onReview={onReviewGoalDraft} onSuggest={onSuggestReply}/> : null}
<CollaborationCard request={item.message.collaboration} />
<ReturnDeliveryStatus delivery={item.message.returnDelivery} />
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { WorkspaceTranslate } from "./i18n";
import type { WorkspaceActionPreviewRequest } from "./personal-workspace-model";

type GoalContent = { objective: string; completion: string; boundary: string };

export function goalCreateContent(input: GoalContent, t: WorkspaceTranslate) {
const objective = input.objective.trim();
const completion = input.completion.trim();
const boundary = input.boundary.trim();
return {
title: objective.slice(0, 80),
objective: [objective, t("goal.objectiveCompletion", { criteria: completion }),
boundary ? t("goal.objectiveBoundary", { boundary }) : ""].filter(Boolean).join("\n"),
completion_criteria: completion, execution_boundary: boundary,
initial_todos: [t("goal.initialTodo", { criteria: completion })],
};
}

/** One preview builder for explicit forms and conversational drafts. No effects. */
export function goalCreateRequest(input: GoalContent & {
agentId: string; permission: string; contextGoalId: string | null; operationId?: string;
}, t: WorkspaceTranslate): WorkspaceActionPreviewRequest {
const content = goalCreateContent(input, t);
return {
actionKind: "goal.create", context: { kind: "manager", goal_id: input.contextGoalId },
idempotencyKey: `workspace-goal.create-${input.operationId ?? crypto.randomUUID()}`,
summary: t("proposal.summary.goalCreate", { title: content.title }),
normalizedParameters: {
...content, goal_id: `goal-${input.operationId ?? crypto.randomUUID()}`,
agent_id: input.agentId, permission: input.permission,
workspace_ref: "current", heartbeat: { enabled: false, cadence: "1d", timezone: "Asia/Shanghai" },
stop_condition: "goal_complete",
},
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.personal-goal-draft { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--pw-line-strong); }
.personal-goal-draft dl { display: grid; gap: 12px; margin: 16px 0; }
.personal-goal-draft dt, .personal-goal-draft small, .personal-goal-draft footer span { color: var(--pw-muted); font-size: 12px; }
.personal-goal-draft dd { margin: 4px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.personal-goal-draft-options { display: flex; flex-wrap: wrap; gap: 8px; }
.personal-goal-draft-options small { flex-basis: 100%; }
.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: 14px; font-weight: 600; }
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useRef, useState } from "react";
import type { GoalDraft } from "../../../../../../loopx/control_plane/collaboration/goal_draft.js";
import { useWorkspaceI18n } from "./i18n";
import "./goal-draft-card.css";

export function GoalDraftCard({ draft, draftId, onReview, onSuggest }: {
draft: GoalDraft;
draftId: string;
onReview?: (draft: GoalDraft, edit?: boolean, draftId?: string) => Promise<void>;
onSuggest?: (text: string) => void;
}) {
const { locale } = useWorkspaceI18n();
const zh = locale === "zh-CN";
const pending = useRef(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const ready = !draft.question && Boolean(draft.completion_criteria.trim());
async function review(edit = false) {
if (!onReview || pending.current) return;
pending.current = true;
setBusy(true);
setError("");
try { await onReview(draft, edit, draftId); }
catch (failure) { setError(failure instanceof Error ? failure.message : String(failure)); }
finally { pending.current = false; setBusy(false); }
}
return <section className="personal-goal-draft" aria-label={zh ? "目标草稿" : "Goal draft"}>
<strong>{zh ? "目标草稿" : "Goal draft"}</strong>
<dl>{[
[zh ? "目标" : "Objective", draft.objective],
[zh ? "完成标准" : "Completion criteria", draft.completion_criteria],
[zh ? "执行边界" : "Execution boundary", draft.execution_boundary],
].map(([label, value]) => <div key={label}><dt>{label}</dt><dd>{value || (zh ? "待补充" : "Not specified")}</dd></div>)}</dl>
{draft.question ? <p>{draft.question}</p> : null}
{onSuggest && draft.options.length ? <div className="personal-goal-draft-options">
{draft.options.map(option => <button type="button" key={option} onClick={() => onSuggest(option)}>{option}</button>)}
<small>{zh ? "点选后可修改再发送,也可以直接输入。" : "Choose a reply to edit before sending, or type your own."}</small>
</div> : null}
{error ? <p role="alert">{error}</p> : null}
<footer><span>{zh ? "尚未创建或启动" : "Not created or started"}</span>
{onReview ? <>
{ready ? <button type="button" disabled={busy} onClick={() => void review(true)}>{zh ? "修改" : "Edit"}</button> : null}
<button type="button" disabled={busy} onClick={() => void review()}>{busy ? (zh ? "正在准备…" : "Preparing…") : ready ? (zh ? "预览创建" : "Preview creation") : (zh ? "补充目标" : "Refine goal")}</button>
</> : null}
</footer>
</section>;
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { GoalDraft } from "../../../../../../loopx/control_plane/collaboration/goal_draft.js";
import type { CollaborationReadback, LoopXModeSettings } from "../../data/chat-model";
import type { TeamPlanAppliedOutcome } from "./team-plan-preview";
import type { ActionReviewPlan } from "../../../../../../loopx/control_plane/presentation/action_review_plan.js";
Expand Down Expand Up @@ -285,6 +286,7 @@ export type WorkspaceActionPreview = {
};

export type WorkspaceMessage = {
goalDraft?: GoalDraft | null;
activity?: string[];
collaboration?: CollaborationReadback;
agentLabel?: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { goalCreateRequest } from "./goal-create-request";
import { GoalDraftCard } from "./goal-draft-card";
import type { GoalDraft } from "../../../../../../loopx/control_plane/collaboration/goal_draft.js";
import { CollaborationCard } from "./collaboration-card";
import {
compileActionReviewPlan,
Expand Down Expand Up @@ -257,13 +260,17 @@ function ManagerConversationTray({
messages,
onClose,
onDraftTask,
onReviewGoalDraft,
onSuggestReply,
onOpenConversation,
title,
}: {
agentLabel?: string;
messages: Array<Extract<WorkspaceTimelineItem, { kind: "message" }>['message']>;
onClose?: () => void;
onDraftTask?: (text: string) => void;
onReviewGoalDraft?: (draft: GoalDraft, edit?: boolean, draftId?: string) => Promise<void>;
onSuggestReply?: (text: string) => void;
onOpenConversation: () => void;
title?: string;
}) {
Expand Down Expand Up @@ -323,6 +330,7 @@ function ManagerConversationTray({
<div className="personal-manager-conversation-bubble">
{message.role === "user" ? <p>{message.text}</p> : <MarkdownText text={message.text} />}
{message.pending ? <small>{t("conversation.agentPending")}</small> : null}
{message.role === "assistant" && !message.pending && message.goalDraft ? <GoalDraftCard draftId={`${message.sourceSessionId ?? ""}:${message.id}`} draft={message.goalDraft} onReview={onReviewGoalDraft} onSuggest={onSuggestReply}/> : null}
<CollaborationCard request={message.collaboration} />
<ReturnDeliveryStatus delivery={message.returnDelivery} />
</div>
Expand Down Expand Up @@ -821,6 +829,25 @@ export function PersonalWorkspacePage({
function setComposer(value: string) {
setComposerDraft(composerDraftKey, value);
}
async function reviewGoalDraft(draft: GoalDraft, edit = false, draftId = "") {
// Source message + reviewed contents survive retry without merging distinct requests.
if (!edit && !draft.question && draft.completion_criteria.trim()) {
const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(
JSON.stringify([draftId, draft, selectedAgentId, locale])));
const operationId = Array.from(new Uint8Array(bytes), value => value.toString(16).padStart(2, "0")).join("");
await createPreview(goalCreateRequest({ objective: draft.objective,
completion: draft.completion_criteria, boundary: draft.execution_boundary,
permission: "read_only", agentId: selectedAgentId, contextGoalId: null, operationId }, t));
return;
}
setActionDraft({ kind: "goal", goalId: null, goalTitle: "", agentId: selectedAgentId,
text: draft.objective, completionCriteria: draft.completion_criteria,
executionBoundary: draft.execution_boundary, permission: "read_only" });
}
function suggestReply(text: string) {
setComposer(composer ? `${composer}\n${text}` : text);
composerRef.current?.focus();
}
// The steward prompt set is owned by the client model; the quick-prompt row
// reuses it so one affordance answers "what now / what blocks / what is proven".
function stewardPromptText(id: string) {
Expand Down Expand Up @@ -1781,7 +1808,7 @@ export function PersonalWorkspacePage({
run={activeSessionRun}
/>
) : null}
<ChannelTimeline items={visibleTimelineItems} onSelect={setSelection} selectedGoal={selectedGoal}
<ChannelTimeline onReviewGoalDraft={readOnly ? undefined : reviewGoalDraft} onSuggestReply={readOnly ? undefined : suggestReply} items={visibleTimelineItems} onSelect={setSelection} selectedGoal={selectedGoal}
onSteerTurn={!readOnly && callbacks.onSteerConversationTurn
? (turnId, text, ingressId) => callbacks.onSteerConversationTurn!(selectedGoal.goalId, turnId, text, ingressId)
: undefined}
Expand All @@ -1793,7 +1820,7 @@ export function PersonalWorkspacePage({
) : !managerChatOpen ? (
<ManagerHomeBoard goals={workspaceGoals} onRetry={() => void callbacks.onRefresh?.()} onSelectGoal={selectGoal} systemHealth={model.systemHealth} />
) : (
<ChannelTimeline items={managerChatItems} onSelect={setSelection} selectedGoal={null} showManagerTeamResults
<ChannelTimeline onReviewGoalDraft={readOnly ? undefined : reviewGoalDraft} onSuggestReply={readOnly ? undefined : suggestReply} items={managerChatItems} onSelect={setSelection} selectedGoal={null} showManagerTeamResults
onSteerTurn={!readOnly && callbacks.onSteerConversationTurn
? (turnId, text, ingressId) => callbacks.onSteerConversationTurn!("manager", turnId, text, ingressId)
: undefined}
Expand All @@ -1809,7 +1836,7 @@ export function PersonalWorkspacePage({
<div className="personal-read-only-notice"><strong>{t("source.readOnlyNoticeTitle")}</strong><span>{t("source.readOnlyNoticeDescription")}</span></div>
) : <>
{!selectedGoal && !managerChatOpen && managerConversationReceiptVisible && managerMessages.length ? (
<ManagerConversationTray
<ManagerConversationTray onReviewGoalDraft={reviewGoalDraft} onSuggestReply={suggestReply}
messages={managerMessages}
onClose={() => setManagerConversationReceiptVisible(false)}
onOpenConversation={() => {
Expand All @@ -1818,7 +1845,7 @@ export function PersonalWorkspacePage({
}} />
) : null}
{selectedGoal && selectedGoalTab !== "chat" && goalConversationReceiptVisible && goalMessages.length ? (
<ManagerConversationTray
<ManagerConversationTray onReviewGoalDraft={reviewGoalDraft} onSuggestReply={suggestReply}
agentLabel={selectedAgentLabel}
messages={goalMessages}
onClose={() => setGoalConversationReceiptVisible(false)}
Expand Down
Loading