Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ const en = {
"feedback.preparingPreview": "Preparing confirmation preview: {title}",
"feedback.previewFailed": "Could not prepare the confirmation preview: {error}",
"feedback.sendFailed": "Send failed: {error}",
"feedback.proposalDraftFailed": "The reply suggested a task, but its draft could not be prepared. Ask again to retry.",
"history.loading": "Reading conversation history…",
"history.partial": "Some history is temporarily unavailable. Available messages are shown.",
"history.unavailable": "Conversation history is temporarily unavailable. Retrying will only read records.",
Expand Down Expand Up @@ -1317,6 +1318,7 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"feedback.preparingPreview": "正在准备确认预览:{title}",
"feedback.previewFailed": "无法准备确认预览:{error}",
"feedback.sendFailed": "发送失败:{error}",
"feedback.proposalDraftFailed": "回答里提出了任务建议,但草稿未能生成。可以再问一次重试。",
"history.loading": "正在读取会话记录…",
"history.partial": "部分历史暂时无法读取,已显示可用消息。",
"history.unavailable": "会话历史暂时无法读取,重试只会读取记录。",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ assert.match(styles, /\.personal-proposal-row\[data-action-kind="operation\.exec
assert.match(drawer, /selection\.item\.actionKind !== "operation\.execute"/, "Dashboard hides generic local controls for authenticated group operations");
assert.match(dashboard, /response\.protected_action/, "Agent semantic protected intent is projected only after the Chat response");
assert.match(dashboard, /normalizedMessage\.includes\(normalizedTarget\)/, "A model-invented protected target cannot reach typed preview");
assert.match(page, /if \(semanticPreview\) await createPreview\(semanticPreview\)/, "Semantic intent still enters the typed preview boundary");
assert.match(page, /if \(previews\?\.decision\) await createPreview\(previews\.decision\)/, "Semantic intent still enters the typed preview boundary");
assert.match(page, /previews\.candidates\.map\(\(request\) => createPreview\(request, \{ select: false \}\)\)/, "Agent candidate proposals enter the same typed preview boundary without taking the drawer");
for (const legacyClassifier of ["hasHeartbeatIntent", "hasMonitorIntent", "hasTodoCreationIntent", "isExecutionIntent"]) {
assert.doesNotMatch(page, new RegExp(`function ${legacyClassifier}`), `${legacyClassifier} no longer bypasses the Router contract`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,7 @@ export type PersonalWorkspaceCallbacks = {
agentId: string,
goalId: string | null,
attachments?: WorkspaceImageAttachment[],
) => void | WorkspaceActionPreviewRequest | Promise<void | WorkspaceActionPreviewRequest>;
) => void | WorkspaceSendPreviews | Promise<void | WorkspaceSendPreviews>;
onPrepareLoopX?: (agentId: string, goalId: string) => Promise<string>;
onStartLoopX?: (operation: "start" | "resume", agentId: string, goalId: string,
settings?: LoopXModeSettings) => void;
Expand All @@ -481,6 +481,14 @@ export type PersonalWorkspaceCallbacks = {
onToggleGoalAutoNotify?: (options: { autoNotify: boolean; goalId: string }) => Promise<{ ok: boolean; blocker?: string; public_summary?: string; status?: string }>;
};

// What one send hands back for review: at most one decision the owner reviews
// now (it opens the drawer) plus candidate cards left in the conversation, such
// as an Agent's Todo proposals. One answer may carry both.
export type WorkspaceSendPreviews = {
candidates?: WorkspaceActionPreviewRequest[];
decision?: WorkspaceActionPreviewRequest;
};

export type WorkspaceActionPreviewRequest = {
actionKind: WorkspaceActionPreview["actionKind"];
context: Record<string, unknown>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,7 @@ export function PersonalWorkspacePage({
managerRuntime,
model,
readOnly = false,
typedActionsRevision = 0,
selectedAgentId: controlledAgentId,
selectedGoalId: controlledGoalId,
statusSourceControl,
Expand All @@ -778,6 +779,9 @@ export function PersonalWorkspacePage({
model: WorkspaceModel;
ownerLabel?: string;
readOnly?: boolean;
// Bumped when typed previews were stored outside this page, so the page
// re-reads the store instead of waiting for the next mount.
typedActionsRevision?: number;
selectedAgentId?: string;
selectedGoalId?: string | null;
statusSourceControl?: StatusSourceControl;
Expand Down Expand Up @@ -818,6 +822,9 @@ export function PersonalWorkspacePage({
const [refreshState, setRefreshState] = useState<"idle" | "loading" | "done" | "error">("idle");
const [sessionProposalIds, setSessionProposalIds] = useState<string[]>([]);
const [managerChannelProposalIds, setManagerChannelProposalIds] = useState<string[]>([]);
// Cards this page created from the Manager channel. A card created from a
// Goal conversation stays in that Goal's timeline and never joins Manager Chat.
const [managerSessionProposalIds, setManagerSessionProposalIds] = useState<string[]>([]);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [theme, setTheme] = useState<WorkspaceTheme>(readWorkspaceTheme);
const [goalContexts, setGoalContexts] = useState<Record<string, GoalRepositoryContext>>({});
Expand Down Expand Up @@ -1040,9 +1047,9 @@ export function PersonalWorkspacePage({
const composerBlocked = sending || conversationTurnRunning;
const managerChatItems = useMemo(
() => items.filter((item) => item.kind === "message"
|| (item.kind === "proposal" && (sessionProposalIds.includes(item.proposal.previewId)
|| (item.kind === "proposal" && (managerSessionProposalIds.includes(item.proposal.previewId)
|| managerChannelProposalIds.includes(item.proposal.previewId)))),
[items, sessionProposalIds, managerChannelProposalIds],
[items, managerSessionProposalIds, managerChannelProposalIds],
);
const conversationOpen = selectedGoal ? selectedGoalTab === "chat" : managerChatOpen;
const conversationMessages = selectedGoal ? goalMessages : managerMessages;
Expand Down Expand Up @@ -1167,12 +1174,22 @@ export function PersonalWorkspacePage({
// The workspace remains usable when the optional local proposal store is unavailable.
});
return () => { cancelled = true; };
}, [readOnly, selectedGoalId, t]);
}, [readOnly, selectedGoalId, t, typedActionsRevision]);

function rememberSessionProposal(previewId: string, channelGoalId: string | null) {
setSessionProposalIds((current) => current.includes(previewId) ? current : [...current, previewId]);
if (channelGoalId === null) {
setManagerSessionProposalIds((current) => current.includes(previewId) ? current : [...current, previewId]);
}
}

async function createPreview(
request: WorkspaceActionPreviewRequest,
options: { select?: boolean } = {},
) {
// The card belongs to the conversation on screen when the request started
// (this render's selectedGoalId), even if its answer lands after the owner
// moved elsewhere.
if (readOnly) throw new Error(t("source.readOnlyWriteError"));
let local: WorkspaceActionPreview;
try {
Expand Down Expand Up @@ -1210,7 +1227,7 @@ export function PersonalWorkspacePage({
workspaceCandidates,
};
}
setSessionProposalIds((current) => current.includes(local.previewId) ? current : [...current, local.previewId]);
rememberSessionProposal(local.previewId, selectedGoalId);
setProposals((current) => ({ ...current, [local.previewId]: local }));
if (options.select !== false) setSelection({ item: local, kind: "proposal" });
return local;
Expand Down Expand Up @@ -1542,7 +1559,9 @@ export function PersonalWorkspacePage({
},
onTransitionProposal: async (proposal, transition) => {
const transitioned = workspaceProposal(await transitionTypedAction(proposal.previewId, transition), t);
setSessionProposalIds((current) => current.includes(transitioned.previewId) ? current : [...current, transitioned.previewId]);
const managerOwned = managerSessionProposalIds.includes(proposal.previewId)
|| managerChannelProposalIds.includes(proposal.previewId);
rememberSessionProposal(transitioned.previewId, managerOwned ? null : proposal.goalId ?? selectedGoalId);
setProposals((current) => {
const next = { ...current };
if (transition === "regenerate") delete next[proposal.previewId];
Expand Down Expand Up @@ -1652,8 +1671,13 @@ export function PersonalWorkspacePage({
try {
if (!selectedGoalId) setManagerConversationReceiptVisible(true);
else if (selectedGoalTab !== "chat") setGoalConversationReceiptVisible(true);
const semanticPreview = await callbacks.onSendMessage?.(message, selectedAgentId, selectedGoalId, pendingImages.length ? pendingImages : undefined);
if (semanticPreview) await createPreview(semanticPreview);
const previews = await callbacks.onSendMessage?.(message, selectedAgentId, selectedGoalId, pendingImages.length ? pendingImages : undefined);
if (previews?.candidates?.length) {
const drafted = await Promise.allSettled(previews.candidates.map((request) => createPreview(request, { select: false })));
if (drafted.some((result) => result.status === "rejected")) setActionFeedback(t("feedback.proposalDraftFailed"));
}
// The decision is created last so it keeps the drawer selection.
if (previews?.decision) await createPreview(previews.decision);
} catch (error) {
if (!messageOverride) {
setComposer(message);
Expand Down
Loading
Loading