Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
46d16c5
fix(workspace): wait for a running Turn before sending
songoow Sep 28, 2026
4929e8f
test(workspace): cover the composer during a recovered running Turn
songoow Sep 28, 2026
ab73c54
Merge remote-tracking branch 'origin/main' into codex/workspace-compo…
songoow Sep 29, 2026
4ee26f4
fix(workspace): retire a cancelled recovery's pending reply
songoow Sep 29, 2026
9741362
fix(workspace): adopt the running Turn a 409 send reports
songoow Sep 29, 2026
6c56202
fix(workspace): keep Send closed through the 409 Turn handoff
songoow Sep 29, 2026
0dc1946
fix(workspace): keep the 409 handoff when the Session read fails
songoow Sep 29, 2026
29bc039
fix(workspace): let the 409 handoff own its Turn controls
songoow Sep 29, 2026
d6ad775
Merge remote-tracking branch 'origin/main' into codex/workspace-compo…
songoow Sep 29, 2026
502dd41
fix(workspace): keep attached host follow-ups sendable during a runni…
songoow Sep 30, 2026
3bf27cc
Merge remote-tracking branch 'upstream/main' into review/pr-5261
songoow Sep 30, 2026
d3f7de8
Merge remote-tracking branch 'upstream/main' into review/pr-5261
songoow Sep 30, 2026
b912cf6
Merge origin/main into codex/workspace-composer-running-turn
huangruiteng Sep 30, 2026
be032a6
test(control-plane): stop pinning counts owned by other owners
huangruiteng Sep 30, 2026
2d9fb28
test(workspace): keep the interactive chip's hairline bound current
huangruiteng Sep 30, 2026
9f465c3
Merge origin/main into codex/workspace-composer-running-turn
huangruiteng Sep 30, 2026
3f05069
Merge remote-tracking branch 'origin/main' into codex/workspace-compo…
songoow Sep 30, 2026
77478f8
test(workspace): prove an attached host still queues follow-ups while…
songoow Sep 30, 2026
6438092
fix(workspace): confirm a reported Turn with a fresh Session read
huangruiteng Sep 30, 2026
b26878a
Merge origin/main into codex/workspace-composer-running-turn
huangruiteng Sep 30, 2026
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
15 changes: 14 additions & 1 deletion apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -685,11 +685,24 @@ export type ChatSessionSummary = {
updated_at: string;
last_activity_at: string;
resumable: boolean;
session_mode?: string;
session_mode?: ChatSessionMode;
host_surface?: string | null;
manager_runtime?: ManagerRuntimeSessionReadback | null;
};

/** ``chat_store`` Session modes; an omitted mode is a managed runtime Session. */
export type ChatSessionMode = "managed_runtime" | "attached_host";

/**
* Whether the Chat service queues a message sent while this Session's Turn
* runs, per ``ChatRuntimeController.submit_turn``: an attached host Session
* enqueues bounded follow-ups behind the host's Turn, while a managed runtime
* Session admits one Turn at a time and answers 409 with ``active_turn_id``.
*/
export function chatSessionQueuesFollowUps(session: Pick<ChatSessionSummary, "session_mode">) {
return session.session_mode === "attached_host";
}

export type ManagerRuntimeSessionReadback = {
schema_version: "manager_runtime_session_readback_v0";
runtime_profile: "restricted" | "trusted_owner";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ type HistoryState = {

export type ConversationHistoryStatus = Pick<ReturnType<typeof useConversationHistory>, "phase" | "reading" | "sendBlocked" | "retry">;

/** The newest Session a channel read found for one executor. */
export function currentChannelSession(history: ChatHistory | null, agentId: string) {
return history?.sessions.find((session) => session.agent_id === agentId) ?? null;
}

/** Read recovery shared by steward and Goal conversations; never executes a Turn. */
export function useConversationHistory({ agentId, currentAgentId, channelId, goalId, enabled }: {
agentId?: string;
Expand All @@ -22,29 +27,45 @@ export function useConversationHistory({ agentId, currentAgentId, channelId, goa
const [state, setState] = useState<HistoryState | null>(null);
const retryRead = useRef<(() => void) | null>(null);
const retry = useCallback(() => retryRead.current?.(), []);
const cached = useRef<{ scope: string; history: ChatHistory } | null>(null);
const cachedHistory = (forScope: string) => cached.current?.scope === forScope ? cached.current.history : null;
const params = useRef({ agentId, channelId, goalId, scope });
params.current = { agentId, channelId, goalId, scope };
// A caller that learned of a Turn the cached read cannot show -- the service
// refused a send because that Turn is running -- needs what the Session looks
// like now, not what this hook read earlier. The re-read stays here so the
// conversation keeps a single history owner.
const refresh = useCallback(async () => {
const { agentId, channelId, goalId, scope } = params.current;
const loaded = await fetchChatHistory({ agentId, channelId, goalId });
cached.current = { scope, history: loaded };
setState({ scope, history: loaded, reading: false, failed: false });
return loaded;
}, []);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
let reading = false;
let history: ChatHistory | null = null;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let attempts = 0;
async function read() {
if (cancelled || reading) return;
clearTimeout(retryTimer);
reading = true;
setState({ scope, history, reading: true, failed: false });
const previous = cachedHistory(scope);
setState({ scope, history: previous, reading: true, failed: false });
try {
const loaded = await fetchChatHistory({ agentId, channelId, goalId }, history ?? undefined);
const loaded = await fetchChatHistory({ agentId, channelId, goalId }, previous ?? undefined);
if (cancelled) return;
history = loaded;
setState({ scope, history, reading: false, failed: false });
cached.current = { scope, history: loaded };
setState({ scope, history: loaded, reading: false, failed: false });
} catch {
if (cancelled) return;
setState({ scope, history, reading: false, failed: true });
setState({ scope, history: cachedHistory(scope), reading: false, failed: true });
} finally {
reading = false;
if (!cancelled && (!history || history.unavailableSessionIds.length > 0)) {
const known = cachedHistory(scope);
if (!cancelled && (!known || known.unavailableSessionIds.length > 0)) {
// Back off sustained transport failures; a user can retry immediately.
retryTimer = setTimeout(() => void read(), Math.min(3000 * 2 ** attempts++, 30_000));
}
Expand All @@ -62,11 +83,12 @@ export function useConversationHistory({ agentId, currentAgentId, channelId, goa
const history = current?.history ?? null;
// A channel transcript spans executors; readability of another executor's
// latest session cannot authorize sending into the selected one.
const latest = history?.sessions.find((session) => session.agent_id === currentAgentId);
const latest = currentChannelSession(history, currentAgentId);
const currentSessionReadable = history !== null && (!latest || history.snapshots.some((snapshot) => snapshot.session.session_id === latest.session_id));
return {
history,
currentSession: latest,
refresh,
phase: !enabled ? "ready" as const : !current ? "loading" as const
: current.failed ? "unavailable" as const
: history?.unavailableSessionIds.length ? (history.snapshots.length ? "partial" as const : "unavailable" as const)
Expand Down
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 @@ -1292,6 +1293,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 @@ -749,6 +749,7 @@ function readImageAttachment(file: File, t: WorkspaceTranslate): Promise<Workspa
}

export function PersonalWorkspacePage({
conversationQueuesFollowUps = false,
conversationSessionId,
conversationHistoryState,
agents = [{ agentId: "codex", available: true, capability: "代码与项目执行", label: "Codex" }],
Expand All @@ -764,6 +765,8 @@ export function PersonalWorkspacePage({
statusSourceControl,
serviceNotice,
}: {
/** The bound Session's mode queues a message sent while its Turn runs. */
conversationQueuesFollowUps?: boolean;
conversationSessionId?: string;
conversationHistoryState?: ConversationHistoryStatus;
agents?: WorkspaceAgentOption[];
Expand Down Expand Up @@ -1023,6 +1026,18 @@ export function PersonalWorkspacePage({
setGoalConversationReceiptVisible(true);
}
}, [goalMessages, selectedGoal, selectedGoalTab]);
// A managed runtime Session admits one Turn at a time. 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. Two deliveries stay open because the service queues them behind
// the running Turn: LoopX mode through its own queue, and any message to an
// attached host Session.
const loopxDeliveryOpen = Boolean(conversationSessionId && loopxMode?.session_id === conversationSessionId
&& loopxMode?.enabled && loopxMode.active_turn_id);
const conversationTurnRunning = !loopxDeliveryOpen && !conversationQueuesFollowUps && 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 @@ -1627,6 +1642,7 @@ export function PersonalWorkspacePage({
finally {setSending(false);}
return;
}
if (conversationTurnRunning) return;
if (!messageOverride) {
setComposer("");
setImageAttachments([]);
Expand Down Expand Up @@ -1950,16 +1966,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 @@ -1972,6 +1988,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 @@ -2012,7 +2029,7 @@ export function PersonalWorkspacePage({
rows={1}
value={composer}
/>
<button aria-label={t("composer.send")} disabled={(!composer.trim() && imageAttachments.length === 0) || sending || conversationHistoryState?.sendBlocked} 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 || conversationHistoryState?.sendBlocked} onClick={() => void sendMessage()} title={t("composer.sendMessageHint")} type="button"><Send size={18} /></button>
</div>
{conversationOpen ? <div className="personal-composer-hint">{sending
? (locale === "zh-CN" ? "正在回复 · 修改当前任务请使用“调整本轮”" : "Reply in progress · use Adjust turn to change the current task")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1097,6 +1097,7 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
.personal-composer-images img { width: 100%; height: 100%; object-fit: cover; border: 1px solid var(--pw-line-strong); border-radius: 10px; background: #fff; }
.personal-composer-images button { position: absolute; top: -5px; right: -5px; display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 1px solid var(--pw-line-strong); border-radius: 50%; background: #fff; color: var(--pw-text); cursor: pointer; box-shadow: 0 1px 4px rgb(30 28 20 / 18%); }
.personal-composer-error { margin: 0 0 7px; color: var(--pw-red); font-size: 11.5px; }
.personal-composer-status { margin: 0 0 7px; color: var(--pw-muted); font-size: 12px; }

.personal-context-drawer { display: grid; grid-template-rows: auto minmax(0, 1fr); height: 100vh; }
.personal-drawer-header { display: flex; align-items: flex-start; justify-content: space-between; min-height: 84px; padding: 20px 18px 16px; border-bottom: 1px solid var(--pw-line); }
Expand Down
Loading
Loading