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 @@ -95,6 +95,7 @@ const en = {
"composer.send": "Send",
"composer.sendMessage": "Send a message to LoopX",
"composer.sendMessageHint": "Send message",
"composer.turnRunning": "A turn is in progress. Adjust or interrupt it from the reply, then send.",
"composer.sentImageAlt": "Remove image {name}",
"conversation.agentPending": "Working…",
"conversation.close": "Close conversation receipt",
Expand Down Expand Up @@ -1265,6 +1266,7 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"composer.send": "发送",
"composer.sendMessage": "向 LoopX 发送消息",
"composer.sendMessageHint": "发送消息",
"composer.turnRunning": "本轮回答进行中。可在回答里调整或中断本轮,结束后再发送。",
"composer.sentImageAlt": "移除图片 {name}",
"conversation.agentPending": "正在整理…",
"conversation.close": "关闭对话回执",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,16 @@ export function PersonalWorkspacePage({
setGoalConversationReceiptVisible(true);
}
}, [goalMessages, selectedGoal, selectedGoalTab]);
// The Chat service accepts one Turn per Session. While the current Session shows
// a Turn in flight, a new message would only be rejected, so the composer
// waits and points to the reply's own adjust/interrupt controls. LoopX mode
// delivers into a running Turn through its own queue, so it stays open.
const loopxDeliveryOpen = Boolean(conversationSessionId && loopxMode?.session_id === conversationSessionId
&& loopxMode?.enabled && loopxMode.active_turn_id);
const conversationTurnRunning = !loopxDeliveryOpen && Boolean(conversationSessionId)
&& managerMessages.some((message) => message.pending && Boolean(message.sourceTurnId)
&& message.sourceSessionId === conversationSessionId);
const composerBlocked = sending || conversationTurnRunning;
const managerChatItems = useMemo(
() => items.filter((item) => item.kind === "message"
|| (item.kind === "proposal" && (sessionProposalIds.includes(item.proposal.previewId)
Expand Down Expand Up @@ -1577,6 +1587,7 @@ export function PersonalWorkspacePage({
finally {setSending(false);}
return;
}
if (conversationTurnRunning) return;
if (!messageOverride) {
setComposer("");
setImageAttachments([]);
Expand Down Expand Up @@ -1867,16 +1878,16 @@ export function PersonalWorkspacePage({
<summary>{locale === "zh-CN" ? "快捷提问" : "Suggestions"}</summary>
{selectedGoal ? (
<div className="personal-quick-prompts">
<button aria-label={t("composer.nextAction")} disabled={sending} onClick={() => void sendMessage(t("composer.nextActionPrompt"))} title={t("composer.sendMessageHint")} type="button"><MessageCircleQuestion size={13} /><span>{t("composer.nextAction")}</span></button>
<button aria-label={t("composer.agentProgress")} disabled={sending} onClick={() => void sendMessage(t("composer.agentProgressPrompt"))} title={t("composer.sendMessageHint")} type="button"><Send size={13} /><span>{t("composer.agentProgress")}</span></button>
<button aria-label={t("composer.nextAction")} disabled={composerBlocked} onClick={() => void sendMessage(t("composer.nextActionPrompt"))} title={t("composer.sendMessageHint")} type="button"><MessageCircleQuestion size={13} /><span>{t("composer.nextAction")}</span></button>
<button aria-label={t("composer.agentProgress")} disabled={composerBlocked} onClick={() => void sendMessage(t("composer.agentProgressPrompt"))} title={t("composer.sendMessageHint")} type="button"><Send size={13} /><span>{t("composer.agentProgress")}</span></button>
<button aria-label={t("composer.monitor")} disabled={sending} onClick={() => prepareScheduleDraft("monitor", selectedGoalId)} title={t("composer.sendMessageHint")} type="button"><CalendarClock size={13} /><span>{t("composer.monitor")}</span></button>
<button aria-label={t("composer.blockers")} disabled={sending || !stewardPromptText("gate")} onClick={() => void sendMessage(stewardPromptText("gate"))} title={t("composer.sendMessageHint")} type="button"><AlertCircle size={13} /><span>{t("composer.blockers")}</span></button>
<button aria-label={t("composer.evidence")} disabled={sending || !stewardPromptText("evidence")} onClick={() => void sendMessage(stewardPromptText("evidence"))} title={t("composer.sendMessageHint")} type="button"><FileText size={13} /><span>{t("composer.evidence")}</span></button>
<button aria-label={t("composer.blockers")} disabled={composerBlocked || !stewardPromptText("gate")} onClick={() => void sendMessage(stewardPromptText("gate"))} title={t("composer.sendMessageHint")} type="button"><AlertCircle size={13} /><span>{t("composer.blockers")}</span></button>
<button aria-label={t("composer.evidence")} disabled={composerBlocked || !stewardPromptText("evidence")} onClick={() => void sendMessage(stewardPromptText("evidence"))} title={t("composer.sendMessageHint")} type="button"><FileText size={13} /><span>{t("composer.evidence")}</span></button>
</div>
) : (
<div className="personal-quick-prompts">
<button aria-label={t("composer.globalTasks")} disabled={sending} onClick={() => void sendMessage(t("composer.globalTasksPrompt"))} title={t("composer.sendMessageHint")} type="button"><MessageCircleQuestion size={13} /><span>{t("composer.globalTasks")}</span></button>
<button aria-label={t("composer.globalProgress")} disabled={sending} onClick={() => void sendMessage(t("composer.globalProgressPrompt"))} title={t("composer.sendMessageHint")} type="button"><Send size={13} /><span>{t("composer.globalProgress")}</span></button>
<button aria-label={t("composer.globalTasks")} disabled={composerBlocked} onClick={() => void sendMessage(t("composer.globalTasksPrompt"))} title={t("composer.sendMessageHint")} type="button"><MessageCircleQuestion size={13} /><span>{t("composer.globalTasks")}</span></button>
<button aria-label={t("composer.globalProgress")} disabled={composerBlocked} onClick={() => void sendMessage(t("composer.globalProgressPrompt"))} title={t("composer.sendMessageHint")} type="button"><Send size={13} /><span>{t("composer.globalProgress")}</span></button>
<button aria-label={t("composer.createGoal")} onClick={requestGoalCreate} title={t("composer.createGoalHint")} type="button"><Plus size={13} /><span>{t("composer.createGoal")}</span></button>
</div>
)}
Expand All @@ -1889,6 +1900,7 @@ export function PersonalWorkspacePage({
</figure>
))}</div> : null}
{imageAttachmentError ? <p className="personal-composer-error" role="alert">{imageAttachmentError}</p> : null}
{conversationTurnRunning ? <p className="personal-composer-status" role="status">{t("composer.turnRunning")}</p> : null}
<div
className="personal-channel-composer"
onDragOver={(event) => {
Expand Down Expand Up @@ -1929,7 +1941,7 @@ export function PersonalWorkspacePage({
rows={1}
value={composer}
/>
<button aria-label={t("composer.send")} disabled={(!composer.trim() && imageAttachments.length === 0) || sending} onClick={() => void sendMessage()} title={t("composer.sendMessageHint")} type="button"><Send size={18} /></button>
<button aria-label={t("composer.send")} disabled={(!composer.trim() && imageAttachments.length === 0) || composerBlocked} onClick={() => void sendMessage()} title={t("composer.sendMessageHint")} type="button"><Send size={18} /></button>
</div>
</>}
</div>
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

80 changes: 78 additions & 2 deletions apps/presentation/dashboard/src/views/dashboard-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1401,6 +1401,9 @@ function PersonalGoalHome({
const [sendingContextId, setSendingContextId] = useState<string | null>(null);
const [runtimeBindings, setRuntimeBindings] = useState<Record<string, PersonalRuntimeBinding>>({});
const [executionSessions, setExecutionSessions] = useState<ChatSessionSummary[]>([]);
// Bumped when the service reports a running Turn this page did not know
// about, so the Turn recovery effect re-reads the Session and adopts it.
const [turnRecoveryRequest, setTurnRecoveryRequest] = useState(0);
const [executionDiscoveryError, setExecutionDiscoveryError] = useState<"partial" | "offline" | null>(null);
const [executionSessionSnapshots, setExecutionSessionSnapshots] = useState<Record<string, ChatSessionSnapshot>>({});
// undefined: not read yet; null: the session owner could not be read.
Expand All @@ -1413,6 +1416,12 @@ function PersonalGoalHome({
const streamControllers = useRef(new Map<string, AbortController>());
const interruptedTurnIds = useRef(new Set<string>());
const recoveringTurnKeys = useRef(new Set<string>());
// A running Turn a 409 reported, keyed by context: its pending reply holds
// the composer closed until the recovery effect adopts it or an
// authoritative Session read finds no such Turn, so the handoff never leaves
// a sendable gap. A failed read is no such finding: it keeps the handoff and
// counts the attempt toward the next re-read's backoff.
const turnHandoffs = useRef(new Map<string, { failedReads: number; messageId: number; turnId: string }>());
const agentMenuRef = useRef<HTMLDivElement>(null);
const agentTriggerRef = useRef<HTMLButtonElement>(null);
const detailsCloseRef = useRef<HTMLButtonElement>(null);
Expand Down Expand Up @@ -1595,6 +1604,8 @@ function PersonalGoalHome({
let cancelled = false;
let recoveryController: AbortController | null = null;
let latestDiscoveredSessionId: string | null = null;
let sessionReadFailed = false;
let handoffRetryTimer: number | undefined;
void (async () => {
try {
const history = await fetchChatHistory({
Expand Down Expand Up @@ -1691,7 +1702,9 @@ function PersonalGoalHome({
recoveryController = new AbortController();
streamControllers.current.set(targetContextId, recoveryController);
let streamedText = "";
const streamingMessageId = appendManagerAssistantMessage(targetContextId, {
const handoff = turnHandoffs.current.get(targetContextId);
if (handoff?.turnId === activeTurnId) turnHandoffs.current.delete(targetContextId);
const streamingMessageId = handoff?.turnId === activeTurnId ? handoff.messageId : appendManagerAssistantMessage(targetContextId, {
activity: ["正在恢复进行中的 Agent 回合"],
sourceTurnId: activeTurnId,
sourceSessionId: created.session_id,
Expand Down Expand Up @@ -1774,6 +1787,15 @@ function PersonalGoalHome({
text: interrupted ? [streamedText.trim(), "已中断。你可以在当前会话继续发送消息。"].filter(Boolean).join("\n\n") : error instanceof Error ? error.message : "无法恢复进行中的 Agent 回合。",
});
} finally {
// A cancelled recovery never settles its placeholder. Retire it, so
// it cannot stay pending beside the placeholder of the recovery that
// replaces it when the user returns to this conversation.
if (cancelled) {
setMessagesByContext((messages) => ({
...messages,
[targetContextId]: (messages[targetContextId] ?? []).filter((message) => message.id !== streamingMessageId),
}));
}
recoveringTurnKeys.current.delete(recoveryKey);
if (activeTurnIds.current.get(targetContextId) === activeTurnId) {
activeTurnIds.current.delete(targetContextId);
Expand All @@ -1793,6 +1815,9 @@ function PersonalGoalHome({
}
} catch (error) {
if (cancelled) return;
// The service refusing the resume is an answer about the Session; any
// other failure left this run without one.
sessionReadFailed = !(error instanceof ChatApiError && error.payload.error_code === "resume_failed");
if (error instanceof ChatApiError && error.payload.error_code === "resume_failed") {
newSessionRequired.current.add(sessionKey);
if (latestDiscoveredSessionId) {
Expand All @@ -1804,13 +1829,37 @@ function PersonalGoalHome({
});
}
}
} finally {
// A reported Turn this run read the Session but did not adopt has
// ended (or belongs to another Session), so its reply no longer holds
// the composer. When the read itself failed the Turn may still run:
// keep the reply pending, with its Turn controls, and read again.
const unadopted = turnHandoffs.current.get(targetContextId);
if (!cancelled && unadopted && sessionReadFailed) {
const failedReads = unadopted.failedReads + 1;
turnHandoffs.current.set(targetContextId, { ...unadopted, failedReads });
updateManagerAssistantMessage(targetContextId, unadopted.messageId, {
activity: ["暂时无法读取会话状态,正在重试"],
});
handoffRetryTimer = window.setTimeout(
() => setTurnRecoveryRequest((current) => current + 1),
Math.min(1000 * 2 ** (failedReads - 1), 10_000),
);
} else if (!cancelled && unadopted) {
turnHandoffs.current.delete(targetContextId);
setMessagesByContext((messages) => ({
...messages,
[targetContextId]: (messages[targetContextId] ?? []).filter((message) => message.id !== unadopted.messageId),
}));
}
}
})();
return () => {
cancelled = true;
recoveryController?.abort();
window.clearTimeout(handoffRetryTimer);
};
}, [contextId, model.goals[0]?.goalId, readOnly, selectedGoal?.goalId, selectedAgent.agentId, selectedAgent.available, selectedAgent.label, selectedAgents]);
}, [contextId, model.goals[0]?.goalId, readOnly, selectedGoal?.goalId, selectedAgent.agentId, selectedAgent.available, selectedAgent.label, selectedAgents, turnRecoveryRequest]);

useEffect(() => {
if (readOnly) return;
Expand Down Expand Up @@ -2080,6 +2129,7 @@ function PersonalGoalHome({
const sessionKey = `${targetContextId}:${selectedRoute.agentId}`;
let streamingMessageId: number | null = null;
let submittedTurnId: string | undefined;
let submittedSessionId: string | undefined;
let streamedText = "";
try {
let sessionId = targetContextId === "manager" ? sessionIds.current.get(sessionKey) : await prepareGoalConversation(targetContextId, selectedRoute.agentId);
Expand All @@ -2106,6 +2156,7 @@ function PersonalGoalHome({
});
newSessionRequired.current.delete(sessionKey);
}
submittedSessionId = sessionId;
streamingMessageId = appendManagerAssistantMessage(targetContextId, {
activity: [targetContextId === "manager" ? "正在连接管家" : "正在连接 Agent"],
agentLabel: answerIdentityLabel(targetContextId, selectedRoute.label),
Expand Down Expand Up @@ -2242,6 +2293,31 @@ function PersonalGoalHome({
return;
}
const payloadError = error instanceof ChatApiError ? error.payload : null;
const runningTurnId = !route?.loopxMode && typeof payloadError?.active_turn_id === "string"
? payloadError.active_turn_id
: "";
if (runningTurnId) {
// The Session already runs a Turn this page had not seen, so the
// message was not accepted. Withdraw it and turn its reply into the
// running Turn's pending reply at once, so the composer stays closed
// while the recovery effect re-reads the Session and adopts that
// reply. Rejecting the send keeps the draft.
setMessagesByContext((messages) => ({
...messages,
[targetContextId]: (messages[targetContextId] ?? []).filter((message) => message.id !== userMessageId),
}));
if (streamingMessageId !== null) {
updateManagerAssistantMessage(targetContextId, streamingMessageId, {
activity: ["正在接管进行中的 Agent 回合"],
pending: true,
sourceSessionId: submittedSessionId,
sourceTurnId: runningTurnId,
});
turnHandoffs.current.set(targetContextId, { failedReads: 0, messageId: streamingMessageId, turnId: runningTurnId });
}
if (targetContextId === contextId) setTurnRecoveryRequest((current) => current + 1);
throw new ChatApiError(t("composer.turnRunning"), payloadError ?? {});
}
if (payloadError && sessionInvalidatedByPayload(payloadError)) {
sessionIds.current.delete(sessionKey);
}
Expand Down
Loading
Loading