Fix mission viewing UX issues

- Fix URL not updating when switching missions (add router.replace)
- Fix "Agent is working..." showing for non-running missions
- Fix streaming indicator showing for wrong mission
- Improve thinking bubble: larger max-height, scroll, "Processing..." placeholder
- Don't auto-collapse thinking for sessions > 30 seconds
- Better error messages for failed/not_feasible missions
This commit is contained in:
Thomas Marchand
2026-01-03 15:32:34 +00:00
parent 4459718984
commit 07378f3fe8
2 changed files with 69 additions and 28 deletions

View File

@@ -286,15 +286,22 @@ function ThinkingItem({
}, [item.done, item.startTime]);
// Auto-collapse when thinking is done (with delay)
// Longer delay for extended thinking sessions to let user see the summary
useEffect(() => {
if (item.done && expanded && !hasAutoCollapsedRef.current) {
const duration = Math.floor((Date.now() - item.startTime) / 1000);
// Don't auto-collapse if thinking was > 30 seconds (user may want to review)
if (duration > 30) {
hasAutoCollapsedRef.current = true; // Mark as handled but don't collapse
return;
}
const timer = setTimeout(() => {
setExpanded(false);
hasAutoCollapsedRef.current = true;
}, 500);
}, 1500); // Longer delay
return () => clearTimeout(timer);
}
}, [item.done, expanded]);
}, [item.done, expanded, item.startTime]);
const formatDuration = (seconds: number) => {
if (seconds < 60) return `${seconds}s`;
@@ -340,12 +347,12 @@ function ThinkingItem({
<div
className={cn(
"overflow-hidden transition-all duration-200 ease-out",
expanded ? "max-h-80 opacity-100 mt-2" : "max-h-0 opacity-0"
expanded ? "max-h-[50vh] opacity-100 mt-2" : "max-h-0 opacity-0"
)}
>
<div className="rounded-lg border border-white/[0.06] bg-white/[0.02] p-3">
<div className="text-xs text-white/50 whitespace-pre-wrap overflow-y-auto max-h-64 leading-relaxed">
{item.content}
<div className="text-xs text-white/50 whitespace-pre-wrap overflow-y-auto max-h-[45vh] leading-relaxed">
{item.content || <span className="italic text-white/30">Processing...</span>}
</div>
</div>
</div>
@@ -1007,16 +1014,19 @@ export default function ControlClient() {
setViewingMissionId(missionId);
fetchingMissionIdRef.current = missionId;
// Update URL immediately so it's shareable/bookmarkable
router.replace(`/control?mission=${missionId}`, { scroll: false });
// Always load fresh history from API when switching missions
// This ensures we don't show stale cached events
try {
const mission = await getMission(missionId);
// Race condition guard: only update if this is still the mission we want
if (fetchingMissionIdRef.current !== missionId) {
return; // Another mission was requested, discard this response
}
const historyItems = missionHistoryToItems(mission);
setItems(historyItems);
// Update cache with fresh data
@@ -1027,30 +1037,33 @@ export default function ControlClient() {
}
} catch (err) {
console.error("Failed to load mission:", err);
// Race condition guard: only update if this is still the mission we want
if (fetchingMissionIdRef.current !== missionId) {
return;
}
// Revert viewing state to avoid filtering out events
const fallbackMission = previousViewingMission ?? currentMissionRef.current;
if (fallbackMission) {
setViewingMissionId(fallbackMission.id);
setViewingMission(fallbackMission);
setItems(missionHistoryToItems(fallbackMission));
router.replace(`/control?mission=${fallbackMission.id}`, { scroll: false });
} else if (previousViewingId && missionItems[previousViewingId]) {
setViewingMissionId(previousViewingId);
setViewingMission(null);
setItems(missionItems[previousViewingId]);
router.replace(`/control?mission=${previousViewingId}`, { scroll: false });
} else {
setViewingMissionId(null);
setViewingMission(null);
setItems([]);
router.replace(`/control`, { scroll: false });
}
}
},
[missionItems, missionHistoryToItems]
[missionItems, missionHistoryToItems, router]
);
// Sync viewingMissionId with currentMission only when there's no explicit viewing mission set
@@ -1721,11 +1734,19 @@ export default function ControlClient() {
{/* Queue count */}
<div className="h-4 w-px bg-white/[0.08]" />
<div className="flex items-center gap-1.5">
<div
className="flex items-center gap-1.5"
title={queueLen > 0 ? `${queueLen} message${queueLen > 1 ? 's' : ''} waiting to be processed` : 'No messages queued'}
>
<span className="text-[10px] uppercase tracking-wider text-white/40">
Queue
</span>
<span className="text-sm font-medium text-white/70 tabular-nums">
<span className={cn(
"text-sm font-medium tabular-nums",
queueLen === 0 && "text-white/70",
queueLen > 0 && queueLen < 3 && "text-amber-400",
queueLen >= 3 && "text-orange-400"
)}>
{queueLen}
</span>
</div>
@@ -1872,7 +1893,7 @@ export default function ControlClient() {
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-indigo-500/10">
{runState === "running" ? (
{viewingMissionIsRunning ? (
<Loader className="h-8 w-8 text-indigo-400 animate-spin" />
) : (
<Bot className="h-8 w-8 text-indigo-400" />
@@ -1880,7 +1901,7 @@ export default function ControlClient() {
</div>
{missionLoading ? (
<Shimmer className="max-w-xs mx-auto" />
) : runState === "running" ? (
) : viewingMissionIsRunning ? (
<>
<h2 className="text-lg font-medium text-white">
Agent is working...
@@ -1904,6 +1925,10 @@ export default function ControlClient() {
<>This mission was interrupted (server shutdown or cancellation). Click the <strong className="text-amber-400">Resume</strong> button in the mission menu to continue where you left off.</>
) : activeMission.status === "blocked" ? (
<>The agent reached its iteration limit ({maxIterations}). You can continue the mission to give it more iterations.</>
) : activeMission.status === "failed" ? (
<>This mission failed without producing any messages.</>
) : activeMission.status === "not_feasible" ? (
<>The agent determined this task was not feasible.</>
) : (
<>This mission was {activeMission.status} without any messages.
{activeMission.status === "completed" && " You can reactivate it to continue."}</>
@@ -2232,7 +2257,7 @@ export default function ControlClient() {
})}
{/* Show streaming indicator when running but no active thinking/phase */}
{runState === "running" &&
{viewingMissionIsRunning &&
items.length > 0 &&
!items.some(
(it) =>

View File

@@ -1435,20 +1435,29 @@ private struct ThinkingBubble: View {
// Expandable content
if isExpanded && !message.content.isEmpty {
Text(message.content)
ScrollView {
Text(message.content)
.font(.caption)
.foregroundStyle(Theme.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 300) // Allow scrolling for long thinking content
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.white.opacity(0.02))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Theme.border, lineWidth: 0.5)
)
.transition(.opacity.combined(with: .scale(scale: 0.95, anchor: .top)))
} else if isExpanded && message.content.isEmpty {
Text("Processing...")
.font(.caption)
.foregroundStyle(Theme.textTertiary)
.lineLimit(message.thinkingDone ? 8 : nil)
.italic()
.foregroundStyle(Theme.textMuted)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.white.opacity(0.02))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Theme.border, lineWidth: 0.5)
)
.transition(.opacity.combined(with: .scale(scale: 0.95, anchor: .top)))
}
}
.onAppear {
@@ -1456,8 +1465,15 @@ private struct ThinkingBubble: View {
}
.onChange(of: message.thinkingDone) { _, done in
if done && !hasAutoCollapsed {
// Auto-collapse after a brief delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
// Don't auto-collapse for extended thinking (> 30 seconds)
// User may want to review what the agent was thinking about
let duration = message.thinkingStartTime.map { Int(Date().timeIntervalSince($0)) } ?? 0
if duration > 30 {
hasAutoCollapsed = true // Mark as handled but don't collapse
return
}
// Auto-collapse shorter thinking after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation(.spring(duration: 0.25)) {
isExpanded = false
hasAutoCollapsed = true