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
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -832,26 +833,9 @@ export function PersonalWorkspacePage({
}
const [managerConversationReceiptVisible, setManagerConversationReceiptVisible] = useState(false);
const [goalConversationReceiptVisible, setGoalConversationReceiptVisible] = useState(false);
const [drafts, setDrafts] = useState<Record<string, string>>(() => {
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<string, string>
: {};
} catch {
return {};
}
});
const [sending, setSending] = useState(false);
const [steering, setSteering] = useState(false);
const [actionDraft, setActionDraft] = useState<WorkspaceActionDraft | null>(null);
const [loopxMode, setLoopxMode] = useState<LoopXModeSnapshot | null>(null);
const [loopxDelivery, setLoopxDelivery] = useState<"queue" | "inbox" | "steer">("queue");
const [loopxMessageReceipt, setLoopxMessageReceipt] = useState("");
const [imageAttachments, setImageAttachments] = useState<WorkspaceImageAttachment[]>([]);
const [imageAttachmentError, setImageAttachmentError] = useState<string | null>(null);
const [actionFeedback, setActionFeedback] = useState<string | null>(null);
const [lifecycleBusyGoalIds, setLifecycleBusyGoalIds] = useState<ReadonlySet<string>>(() => new Set());
const [quickCompletingTodoIds, setQuickCompletingTodoIds] = useState<ReadonlySet<string>>(() => new Set());
const [refreshState, setRefreshState] = useState<"idle" | "loading" | "done" | "error">("idle");
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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.
Expand All @@ -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);}
Expand All @@ -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 }));
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, ConversationInputState>>(() => {
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<K extends keyof ConversationInputState>(field: K) {
return (value: SetStateAction<ConversationInputState[K]>) => 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,
};
}
11 changes: 11 additions & 0 deletions docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Loading