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 af0f6245d2..7cd0d09edc 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,5 +1,6 @@ import { goalCreateRequest } from "./goal-create-request"; import { readSteeringRequest, retainSteeringRequest, retireSteeringRequest } from "./steering-recovery"; +import { useConversationInputState } from "./use-conversation-input-state"; import type { ConversationHistoryStatus } from "../../data/use-conversation-history"; import { GoalDraftCard } from "./goal-draft-card"; import type { GoalDraft } from "../../../../../../loopx/control_plane/collaboration/goal_draft.js"; @@ -832,26 +833,9 @@ export function PersonalWorkspacePage({ } const [managerConversationReceiptVisible, setManagerConversationReceiptVisible] = useState(false); const [goalConversationReceiptVisible, setGoalConversationReceiptVisible] = useState(false); - const [drafts, setDrafts] = useState>(() => { - try { - const raw = window.sessionStorage.getItem("loopx-pw-composer-drafts"); - const parsed = raw ? JSON.parse(raw) : {}; - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? parsed as Record - : {}; - } catch { - return {}; - } - }); - const [sending, setSending] = useState(false); - const [steering, setSteering] = useState(false); const [actionDraft, setActionDraft] = useState(null); const [loopxMode, setLoopxMode] = useState(null); const [loopxDelivery, setLoopxDelivery] = useState<"queue" | "inbox" | "steer">("queue"); - const [loopxMessageReceipt, setLoopxMessageReceipt] = useState(""); - const [imageAttachments, setImageAttachments] = useState([]); - const [imageAttachmentError, setImageAttachmentError] = useState(null); - const [actionFeedback, setActionFeedback] = useState(null); const [lifecycleBusyGoalIds, setLifecycleBusyGoalIds] = useState>(() => new Set()); const [quickCompletingTodoIds, setQuickCompletingTodoIds] = useState>(() => new Set()); const [refreshState, setRefreshState] = useState<"idle" | "loading" | "done" | "error">("idle"); @@ -881,37 +865,17 @@ export function PersonalWorkspacePage({ const actionReadback = useTypedActionReadback(readOnly, selectedGoalId); const selectedAgentId = controlledAgentId ?? localAgentId; const composerDraftKey = `${selectedGoalId ?? "manager"}:${selectedAgentId}`; - const composer = drafts[composerDraftKey] ?? ""; - useEffect(() => { - setImageAttachments([]); - setImageAttachmentError(null); - }, [composerDraftKey]); + const { composer, setComposer, restoreFailedSubmission, + sending, setSending, steering, setSteering, actionFeedback, setActionFeedback, + imageAttachments, setImageAttachments, imageAttachmentError, setImageAttachmentError, + loopxMessageReceipt, setLoopxMessageReceipt, isCurrentConversation, + } = useConversationInputState(composerDraftKey); useEffect(() => { const input = composerRef.current; if (!input) return; input.style.height = "auto"; input.style.height = `${Math.min(input.scrollHeight, 120)}px`; }, [composer, selectedGoalId, managerChatOpen]); - function setComposerDraft(key: string, value: string, expectedValue?: string) { - setDrafts((current) => { - if (expectedValue !== undefined && current[key] !== expectedValue) return current; - const next = { ...current }; - if (value) { - next[key] = value; - } else { - delete next[key]; - } - try { - window.sessionStorage.setItem("loopx-pw-composer-drafts", JSON.stringify(next)); - } catch { - // Storage may be unavailable (private mode); drafts simply stay in memory. - } - return next; - }); - } - 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()) { @@ -1274,7 +1238,7 @@ export function PersonalWorkspacePage({ } rememberSessionProposal(local.previewId, selectedGoalId); setProposals((current) => ({ ...current, [local.previewId]: local })); - if (options.select !== false) setSelection({ item: local, kind: "proposal" }); + if (options.select !== false && isCurrentConversation()) setSelection({ item: local, kind: "proposal" }); return local; } @@ -1710,7 +1674,7 @@ export function PersonalWorkspacePage({ try { await callbacks.onSteerConversationTurn(selectedGoalId ?? "manager", request.turnId, message, request.id); retireSteeringRequest(composerDraftKey, request.id); - if (!messageOverride) setComposerDraft(composerDraftKey, "", composer); + if (!messageOverride) setComposer("", composer); setActionFeedback(locale === "zh-CN" ? "执行器已接收本轮追加指令。" : "The executor accepted instructions for this turn."); } catch (error) { // Unknown delivery retries the original Turn even after it completes. @@ -1733,7 +1697,7 @@ export function PersonalWorkspacePage({ setSending(true); try { const receipt = await sendLoopXMessage(conversationSessionId, message, loopxDelivery); - if (!messageOverride) setComposer(""); + if (!messageOverride) setComposer("", composer); setLoopxMessageReceipt(locale === "zh-CN" ? `${loopxDelivery === "queue" ? "已排队,等待后续回合" : loopxDelivery === "inbox" ? "已进入收件箱" : "已提交纠偏"} · ${receipt.status}` : `${loopxDelivery}: ${receipt.status}`); } catch (error) {setImageAttachmentError(error instanceof Error ? error.message : String(error));} finally {setSending(false);} @@ -1758,8 +1722,7 @@ export function PersonalWorkspacePage({ if (previews?.decision) await createPreview(previews.decision); } catch (error) { if (!messageOverride) { - setComposer(message); - setImageAttachments(pendingImages); + restoreFailedSubmission(message, pendingImages); } const errorMessage = error instanceof Error ? error.message : t("feedback.sendGenericError"); setActionFeedback(t("feedback.sendFailed", { error: errorMessage })); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/use-conversation-input-state.ts b/apps/presentation/dashboard/src/features/personal-workspace/use-conversation-input-state.ts new file mode 100644 index 0000000000..a2d7327528 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/use-conversation-input-state.ts @@ -0,0 +1,77 @@ +import { useEffect, useRef, useState, type SetStateAction } from "react"; +import type { WorkspaceImageAttachment } from "./personal-workspace-model"; + +interface ConversationInputState { + composer: string; + sending: boolean; + steering: boolean; + actionFeedback: string | null; + imageAttachments: WorkspaceImageAttachment[]; + imageAttachmentError: string | null; + loopxMessageReceipt: string; +} + +const emptyInput: ConversationInputState = { + composer: "", + sending: false, + steering: false, + actionFeedback: null, + imageAttachments: [], + imageAttachmentError: null, + loopxMessageReceipt: "", +}; + +// An async callback keeps the key from its originating render. Leaving a +// conversation does not cancel delivery or let its late receipt edit a peer. +// This state is presentation only; Session/Turn ingress still owns effects. +export function useConversationInputState(key: string) { + const [inputs, setInputs] = useState>(() => { + try { + const parsed = JSON.parse(window.sessionStorage.getItem("loopx-pw-composer-drafts") ?? "{}"); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? Object.fromEntries(Object.entries(parsed).filter(([, text]) => typeof text === "string") + .map(([draftKey, text]) => [draftKey, { ...emptyInput, composer: text as string }])) + : {}; + } catch { return {}; } + }); + const savedDrafts = JSON.stringify(Object.fromEntries(Object.entries(inputs) + .filter(([, input]) => input.composer).map(([draftKey, input]) => [draftKey, input.composer]))); + useEffect(() => { + try { window.sessionStorage.setItem("loopx-pw-composer-drafts", savedDrafts); } + catch { /* Storage may be unavailable; input remains in memory. */ } + }, [savedDrafts]); + const visibleKey = useRef(key); + visibleKey.current = key; + + function updateInput(update: (input: ConversationInputState) => ConversationInputState) { + setInputs((current) => { + const input = current[key] ?? emptyInput; + const next = update(input); + return next === input ? current : { ...current, [key]: next }; + }); + } + + function setter(field: K) { + return (value: SetStateAction) => updateInput((input) => { + const next = typeof value === "function" + ? (value as (previous: ConversationInputState[K]) => ConversationInputState[K])(input[field]) + : value; + return { ...input, [field]: next }; + }); + } + + return { + ...(inputs[key] ?? emptyInput), + setComposer: (value: string, expectedValue?: string) => updateInput((input) => + expectedValue !== undefined && input.composer !== expectedValue ? input : { ...input, composer: value }), + restoreFailedSubmission: (text: string, images: WorkspaceImageAttachment[]) => updateInput((input) => + input.composer || input.imageAttachments.length ? input : { ...input, composer: text, imageAttachments: images }), + setSending: setter("sending"), + setSteering: setter("steering"), + setActionFeedback: setter("actionFeedback"), + setImageAttachments: setter("imageAttachments"), + setImageAttachmentError: setter("imageAttachmentError"), + setLoopxMessageReceipt: setter("loopxMessageReceipt"), + isCurrentConversation: () => visibleKey.current === key, + }; +} diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md index 21f493da7b..4686a4414b 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md @@ -167,6 +167,17 @@ receipt → observed work or actionable failure → readable answer in the same promise reload recovery. This tab-local cache is not delivery authority. Acceptance means the executor received the instructions, not that it adopted them or that delegated/team work stopped. Live adoption stays a release gate. +- The shared App composer owns pending submission, instruction receipts, feedback + and unsent images by its existing Goal/Agent conversation key. Waiting in one + conversation cannot lock Send in another. Returning retains that conversation's + actual wait or receipt; a late completion cannot clear a peer's pending request, + replace a newer draft or open an old proposal over the current conversation. + Failed delivery restores the submitted text and images together only while + that composer's draft is still empty; later text or pasted images take precedence. + Navigation now retains unsent images in their original composer, rather than + discarding them; nothing is automatically resent. Packaged browser regressions + inject delayed receipts and a rejected independent request. This qualifies App + state ownership, not live model adoption, host steering or a new effect grant. - The compact receipt and full conversation offer the same controls. Failure ends the live indicator, preserves the request/partial answer and names the next supported action. A completed delegation still shows receiver adoption diff --git a/examples/personal-workspace-browser/composer-session-admission.mjs b/examples/personal-workspace-browser/composer-session-admission.mjs index 446fb81436..06a1872faf 100644 --- a/examples/personal-workspace-browser/composer-session-admission.mjs +++ b/examples/personal-workspace-browser/composer-session-admission.mjs @@ -19,6 +19,15 @@ export const composerSessionAdmissionScenario = { const composerInput = page.getByLabel("向 LoopX 发送消息"); const sendButton = page.getByRole("button", { name: "发送", exact: true }); const turnRunningHint = page.locator(".personal-composer-status", { hasText: "本轮回答进行中" }); + const pasteDraftImage = async (name) => { + await composerInput.evaluate((target, filename) => { + const png = Uint8Array.from(atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jN3sAAAAASUVORK5CYII="), (char) => char.charCodeAt(0)); + const transfer = new DataTransfer(); + transfer.items.add(new File([png], filename, { type: "image/png" })); + target.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: transfer })); + }, name); + await page.locator(".personal-composer-images").getByRole("img", { name, exact: true }).waitFor(); + }; const openGoalChat = async () => { await page.getByTestId("personal-goal-home").waitFor({ state: "visible" }); await page.locator(".personal-goal-link").first().click(); @@ -117,9 +126,71 @@ export const composerSessionAdmissionScenario = { for (let attempt = 0; attempt < 100 && !delayedReceipt; attempt += 1) await page.waitForTimeout(50); if (!delayedReceipt) throw new Error("Retry did not reach the original Turn"); await composerInput.fill("先写下另一条指令"); + await page.locator(".personal-manager-link").click(); + await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "对话", exact: true }).click(); + const managerDraft = "另一件事:给我列三条发布前检查。"; + await composerInput.fill(managerDraft); + if (await sendButton.isDisabled()) throw new Error("Another conversation's pending instruction blocks this composer's Send"); + let managerSubmission; + const managerRoute = "**/api/chat/sessions/*/turns"; + await page.route(managerRoute, (route) => { + if (route.request().method() === "POST" + && route.request().postDataJSON().message === managerDraft) managerSubmission = route; + else return route.fallback(); + }); + await sendButton.click(); + for (let attempt = 0; attempt < 100 && !managerSubmission; attempt += 1) await page.waitForTimeout(50); + if (!managerSubmission) throw new Error("The independent conversation did not submit its own request"); + const nextManagerDraft = "再列一下回滚步骤。"; + await composerInput.fill(nextManagerDraft); + await pasteDraftImage("pasted-during-send.png"); + const newerImage = page.locator(".personal-composer-images").getByRole("img", { name: "pasted-during-send.png", exact: true }); await delayedReceipt(); + await page.waitForTimeout(100); + if (!await sendButton.isDisabled() || await composerInput.inputValue() !== nextManagerDraft + || await page.getByRole("status").filter({ hasText: "执行器已接收本轮追加指令" }).count()) { + throw new Error("A late instruction receipt changed the other conversation's pending state, draft or feedback"); + } + await openGoalChat(); + if (await newerImage.count()) throw new Error("A newer image moved to another conversation"); await page.getByRole("status").filter({ hasText: "执行器已接收本轮追加指令" }).waitFor(); if (await composerInput.inputValue() !== "先写下另一条指令") throw new Error("Delivery erased the draft typed while it was sending"); + await managerSubmission.fulfill({ status: 400, json: { ok: false, error: "独立请求未被接收", delivery_state: "not_delivered", turn_replay_safe: true } }); + await page.waitForTimeout(100); + if (await page.getByRole("status").filter({ hasText: "独立请求未被接收" }).count()) throw new Error("The other conversation's rejection leaked into this view"); + await page.locator(".personal-manager-link").click(); + await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "对话", exact: true }).click(); + await page.locator(".personal-action-feedback", { hasText: "独立请求未被接收" }).waitFor(); + if (await composerInput.inputValue() !== nextManagerDraft || await sendButton.isDisabled()) { + throw new Error("A rejected original request replaced the later draft or left its composer blocked"); + } + if (!await newerImage.count()) throw new Error("Failed delivery discarded a newer image pasted while the request was pending"); + await page.unroute(managerRoute); + let imageOnlySubmission; + await page.route(managerRoute, (route) => { + if (route.request().method() === "POST" && route.request().postDataJSON().message === nextManagerDraft) imageOnlySubmission = route; + else return route.fallback(); + }); + await sendButton.click(); + for (let attempt = 0; attempt < 100 && !imageOnlySubmission; attempt += 1) await page.waitForTimeout(50); + if (!imageOnlySubmission) throw new Error("The image-bearing request did not reach admission"); + await pasteDraftImage("later-image-only.png"); + await imageOnlySubmission.fulfill({ status: 400, json: { ok: false, error: "图片请求未被接收", delivery_state: "not_delivered", turn_replay_safe: true } }); + await page.locator(".personal-action-feedback", { hasText: "图片请求未被接收" }).waitFor(); + if (await composerInput.inputValue() || await newerImage.count() + || !await page.locator(".personal-composer-images").getByRole("img", { name: "later-image-only.png", exact: true }).count()) { + throw new Error("Failure mixed the original text/images into a newer image-only draft"); + } + await page.unroute(managerRoute); + await composerInput.fill(nextManagerDraft); + await page.locator(".personal-composer-images img").waitFor(); + await openGoalChat(); + if (await page.locator(".personal-composer-images img").count()) throw new Error("Unsent images moved to another conversation"); + await page.locator(".personal-manager-link").click(); + await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "对话", exact: true }).click(); + await page.locator(".personal-composer-images img").waitFor(); + if (await composerInput.inputValue() !== nextManagerDraft) throw new Error("Returning lost the unsent request's text"); + await openGoalChat(); if (new Set(adjustments.slice(0, 3).map(row => row.client_ingress_id)).size !== 1) throw new Error("Uncertain retries minted new ingress identities"); await composerInput.fill(instruction); await sendButton.click(); @@ -186,7 +257,7 @@ export const composerSessionAdmissionScenario = { throw new Error("Delivered replay did not read back one stored instruction on the current page"); } await managed.release(); - notes.push("managed Codex: ordinary composer steers its exact Turn while the original send waits, retaining drafts and retry identity, including after completion and reload; restored requests never dispatch automatically"); + notes.push("managed Codex: ordinary composer steers its exact Turn while the original send waits, retaining drafts and retry identity, including after completion and reload; concurrent conversations retain their own pending state, receipts, newer drafts and unsent images; restored requests never dispatch automatically"); const unsupported = await reloadWithRunningTurn("managed_runtime", `turn-unsupported-${Date.now()}`, "external"); await turnRunningHint.waitFor();