Compare commits

...

2 Commits

Author SHA1 Message Date
Haileyesus
0299beccb9 fix: prevent stale codex processing banner
Codex can finish a turn and clear the composer banner while delayed status
checks or parent processing state still believe the session is running.
That leaves a stale path that can re-open the banner after the response
is already complete.

This was visible with Codex responses that render a delayed remote image.
The image load/reflow gives late session-status handling a chance to
reassert processing. A simple text-only prompt did not create the same
render window.

Reproduces with Codex using:

Reply with exactly the following Markdown, no code fence, no explanation:

Start

![scroll reflow test](https://picsum.photos/seed/pr788-scroll/1600/1400)

BOTTOM_SENTINEL_PR_788

Did not reproduce with: hi

The fix marks Codex terminal turns complete before status probes can
report them active, ignores stale processing status after a terminal
event, and only restores local loading when a session newly enters
processing.
2026-06-08 20:27:14 +03:00
Noah
f4a1614a0a fix(sandbox): prevent server SIGHUP on sbx exec exit (#792)
Replace bare background operator with nohup+disown so the cloudcli
server process survives after the sbx exec session terminates.
Also redirects stdout/stderr to /tmp/cloudcli-ui.log for debugging
via `cloudcli sandbox logs`.

Fixes #791

Co-authored-by: NoahHahm <noah@naverz-corp.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Haile <118998054+blackmammoth@users.noreply.github.com>
2026-06-08 15:28:35 +03:00
4 changed files with 53 additions and 3 deletions

View File

@@ -455,7 +455,7 @@ async function sandboxCommand(args) {
await new Promise(resolve => setTimeout(resolve, 5000)); await new Promise(resolve => setTimeout(resolve, 5000));
console.log(`${c.info('▶')} Launching CloudCLI web server...`); console.log(`${c.info('▶')} Launching CloudCLI web server...`);
sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']); sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']);
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`); console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
try { try {
@@ -554,7 +554,7 @@ async function sandboxCommand(args) {
// Step 3: Start CloudCLI inside the sandbox // Step 3: Start CloudCLI inside the sandbox
console.log(`${c.info('▶')} Launching CloudCLI web server...`); console.log(`${c.info('▶')} Launching CloudCLI web server...`);
sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']); sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']);
// Step 4: Forward port // Step 4: Forward port
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`); console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);

View File

@@ -279,6 +279,16 @@ export async function queryCodex(command, options = {}, ws) {
startedAt: new Date().toISOString() startedAt: new Date().toISOString()
}); });
}; };
const markSessionFinished = (id) => {
if (!id) {
return;
}
const session = activeCodexSessions.get(id);
if (session && session.status !== 'aborted') {
session.status = 'completed';
}
};
// Existing sessions can be tracked immediately; new sessions are tracked after thread.started. // Existing sessions can be tracked immediately; new sessions are tracked after thread.started.
if (capturedSessionId) { if (capturedSessionId) {
@@ -324,6 +334,10 @@ export async function queryCodex(command, options = {}, ws) {
continue; continue;
} }
if (event.type === 'turn.completed' || event.type === 'turn.failed') {
markSessionFinished(capturedSessionId || sessionId);
}
const transformed = transformCodexEvent(event); const transformed = transformCodexEvent(event);
// Normalize the transformed event into NormalizedMessage(s) via adapter // Normalize the transformed event into NormalizedMessage(s) via adapter
@@ -354,6 +368,8 @@ export async function queryCodex(command, options = {}, ws) {
// Send completion event // Send completion event
if (!terminalFailure) { if (!terminalFailure) {
markSessionFinished(capturedSessionId || sessionId);
sendMessage(ws, createNormalizedMessage({ sendMessage(ws, createNormalizedMessage({
kind: 'complete', kind: 'complete',
actualSessionId: capturedSessionId || thread.id || sessionId || null, actualSessionId: capturedSessionId || thread.id || sessionId || null,

View File

@@ -98,6 +98,7 @@ export function useChatRealtimeHandlers({
}: UseChatRealtimeHandlersArgs) { }: UseChatRealtimeHandlersArgs) {
const paletteOps = usePaletteOps(); const paletteOps = usePaletteOps();
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null); const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);
const terminalSessionIdsRef = useRef<Set<string>>(new Set());
useEffect(() => { useEffect(() => {
if (!latestMessage) return; if (!latestMessage) return;
@@ -151,6 +152,17 @@ export function useChatRealtimeHandlers({
const isCurrentSession = const isCurrentSession =
statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id); statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
if (msg.isProcessing && terminalSessionIdsRef.current.has(statusSessionId)) {
onSessionInactive?.(statusSessionId);
onSessionNotProcessing?.(statusSessionId);
if (isCurrentSession) {
setIsLoading(false);
setCanAbortSession(false);
setClaudeStatus(null);
}
return;
}
if (msg.isProcessing) { if (msg.isProcessing) {
onSessionActive?.(statusSessionId); onSessionActive?.(statusSessionId);
onSessionProcessing?.(statusSessionId); onSessionProcessing?.(statusSessionId);
@@ -180,6 +192,10 @@ export function useChatRealtimeHandlers({
const sid = msg.sessionId || activeViewSessionId; const sid = msg.sessionId || activeViewSessionId;
if (sid && msg.kind === 'session_created') {
terminalSessionIdsRef.current.delete(sid);
}
// --- Streaming: buffer for performance --- // --- Streaming: buffer for performance ---
if (msg.kind === 'stream_delta') { if (msg.kind === 'stream_delta') {
const text = msg.content || ''; const text = msg.content || '';
@@ -258,6 +274,10 @@ export function useChatRealtimeHandlers({
} }
case 'complete': { case 'complete': {
if (sid) {
terminalSessionIdsRef.current.add(sid);
}
// Flush any remaining streaming state // Flush any remaining streaming state
if (streamTimerRef.current) { if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current); clearTimeout(streamTimerRef.current);
@@ -313,6 +333,10 @@ export function useChatRealtimeHandlers({
} }
case 'error': { case 'error': {
if (sid) {
terminalSessionIdsRef.current.add(sid);
}
setIsLoading(false); setIsLoading(false);
setCanAbortSession(false); setCanAbortSession(false);
setClaudeStatus(null); setClaudeStatus(null);

View File

@@ -131,6 +131,8 @@ export function useChatSessionState({
const pendingInitialScrollRef = useRef(true); const pendingInitialScrollRef = useRef(true);
const messagesOffsetRef = useRef(0); const messagesOffsetRef = useRef(0);
const scrollPositionRef = useRef({ height: 0, top: 0 }); const scrollPositionRef = useRef({ height: 0, top: 0 });
const previousProcessingSessionsRef = useRef<Set<string> | null>(null);
const previousProcessingSessionViewIdRef = useRef<string | null>(null);
const loadAllFinishedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const loadAllFinishedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadAllOverlayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const loadAllOverlayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastLoadedSessionKeyRef = useRef<string | null>(null); const lastLoadedSessionKeyRef = useRef<string | null>(null);
@@ -693,9 +695,17 @@ export function useChatSessionState({
useEffect(() => { useEffect(() => {
const activeViewSessionId = selectedSession?.id || currentSessionId; const activeViewSessionId = selectedSession?.id || currentSessionId;
const previousProcessingSessions = previousProcessingSessionsRef.current;
const previousProcessingSessionViewId = previousProcessingSessionViewIdRef.current;
previousProcessingSessionsRef.current = processingSessions ?? null;
previousProcessingSessionViewIdRef.current = activeViewSessionId ?? null;
if (!activeViewSessionId || !processingSessions) return; if (!activeViewSessionId || !processingSessions) return;
const activeViewSessionChanged = previousProcessingSessionViewId !== activeViewSessionId;
const wasProcessing = previousProcessingSessions?.has(activeViewSessionId) ?? false;
const shouldBeProcessing = processingSessions.has(activeViewSessionId); const shouldBeProcessing = processingSessions.has(activeViewSessionId);
if (shouldBeProcessing && !isLoading) { if (shouldBeProcessing && (!wasProcessing || activeViewSessionChanged) && !isLoading) {
setIsLoading(true); setIsLoading(true);
setCanAbortSession(true); setCanAbortSession(true);
} }