Skip to content

Commit 88820e6

Browse files
committed
Refresh loading UX with inline agent thinking status
1 parent 1fe584d commit 88820e6

5 files changed

Lines changed: 204 additions & 60 deletions

File tree

frontend/src/app/session/SessionPageClient.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,6 @@ function SessionRouteContent() {
369369
onRename={(t) => renameConversation(sessionId, t)}
370370
onDelete={() => openDeleteConfirm(sessionId, conversationTitleFromCache)}
371371
onOpenMap={() => {}}
372-
isSyncing={false}
373372
COLORS={COLORS}
374373
isDark={isDark}
375374
/>
@@ -391,7 +390,6 @@ function SessionRouteContent() {
391390
onRename={(t) => renameConversation(sessionId, t)}
392391
onDelete={() => openDeleteConfirm(sessionId, conversationTitleFromCache)}
393392
onOpenMap={() => {}}
394-
isSyncing={false}
395393
COLORS={COLORS}
396394
isDark={isDark}
397395
/>
@@ -415,7 +413,6 @@ function SessionRouteContent() {
415413
onRename={(t) => renameConversation(sessionId, t)}
416414
onDelete={() => openDeleteConfirm(sessionId, conversationTitleFromCache)}
417415
onOpenMap={() => {}}
418-
isSyncing={false}
419416
COLORS={COLORS}
420417
isDark={isDark}
421418
/>
@@ -466,7 +463,6 @@ function SessionRouteContent() {
466463
onRename={(t) => renameConversation(sessionId, t)}
467464
onDelete={() => openDeleteConfirm(sessionId, conversationTitleFromCache)}
468465
onOpenMap={() => {}}
469-
isSyncing={false}
470466
COLORS={COLORS}
471467
isDark={isDark}
472468
/>
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"use client"
2+
3+
import { useEffect, useState } from "react"
4+
import type { CSSProperties } from "react"
5+
import { FONTS } from "@/lib/constants"
6+
import { useIsDark, useThemeColors } from "@/context/ThemeContext"
7+
8+
export type AgentThinkingStatusPhase = "analyzing" | "planning" | "drafting" | "generating"
9+
10+
export interface AgentThinkingStatusProps {
11+
phase: AgentThinkingStatusPhase
12+
compact?: boolean
13+
showSkeleton?: boolean
14+
className?: string
15+
style?: CSSProperties
16+
}
17+
18+
const PHASE_LABELS: Record<AgentThinkingStatusPhase, string> = {
19+
analyzing: "Agent is analyzing your prompt",
20+
planning: "Agent is planning the response",
21+
drafting: "Agent is drafting an answer",
22+
generating: "Agent is generating the final response",
23+
}
24+
25+
function SkeletonLine({
26+
width,
27+
compact,
28+
}: {
29+
width: string
30+
compact: boolean
31+
}) {
32+
const COLORS = useThemeColors()
33+
const isDark = useIsDark()
34+
return (
35+
<div
36+
aria-hidden="true"
37+
style={{
38+
width,
39+
height: compact ? "8px" : "10px",
40+
borderRadius: "999px",
41+
background: isDark
42+
? "linear-gradient(90deg, rgba(255,255,255,0.07) 18%, rgba(255,255,255,0.14) 50%, rgba(255,255,255,0.07) 82%)"
43+
: "linear-gradient(90deg, rgba(0,0,0,0.06) 18%, rgba(0,0,0,0.12) 50%, rgba(0,0,0,0.06) 82%)",
44+
backgroundSize: "220% 100%",
45+
animation: "fpSkeletonShimmer 1.2s linear infinite",
46+
border: `1px solid ${COLORS.border}`,
47+
}}
48+
/>
49+
)
50+
}
51+
52+
export function useAgentThinkingPhase({
53+
active,
54+
hasStartedGenerating = false,
55+
}: {
56+
active: boolean
57+
hasStartedGenerating?: boolean
58+
}): AgentThinkingStatusPhase {
59+
const [phase, setPhase] = useState<AgentThinkingStatusPhase>("analyzing")
60+
61+
useEffect(() => {
62+
const resetTimer = setTimeout(() => {
63+
if (!active) {
64+
setPhase("analyzing")
65+
return
66+
}
67+
if (hasStartedGenerating) {
68+
setPhase("generating")
69+
return
70+
}
71+
setPhase("analyzing")
72+
}, 0)
73+
74+
const planningTimer = setTimeout(() => {
75+
if (!active || hasStartedGenerating) return
76+
setPhase((prev) => (prev === "analyzing" ? "planning" : prev))
77+
}, 1200)
78+
const draftingTimer = setTimeout(() => {
79+
if (!active || hasStartedGenerating) return
80+
setPhase((prev) => (prev === "analyzing" || prev === "planning" ? "drafting" : prev))
81+
}, 3500)
82+
83+
return () => {
84+
clearTimeout(resetTimer)
85+
clearTimeout(planningTimer)
86+
clearTimeout(draftingTimer)
87+
}
88+
}, [active, hasStartedGenerating])
89+
90+
return phase
91+
}
92+
93+
export function AgentThinkingStatus({
94+
phase,
95+
compact = false,
96+
showSkeleton = true,
97+
className,
98+
style,
99+
}: AgentThinkingStatusProps) {
100+
const COLORS = useThemeColors()
101+
const isDark = useIsDark()
102+
103+
return (
104+
<div
105+
role="status"
106+
aria-live="polite"
107+
aria-atomic="true"
108+
aria-busy="true"
109+
className={className}
110+
style={{
111+
border: `1px solid ${COLORS.border}`,
112+
borderRadius: compact ? "10px" : "12px",
113+
background: isDark ? "rgba(255,255,255,0.02)" : "rgba(0,0,0,0.02)",
114+
padding: compact ? "10px 12px" : "12px 14px",
115+
display: "flex",
116+
flexDirection: "column",
117+
gap: compact ? "6px" : "8px",
118+
transition: "opacity 0.2s ease",
119+
...style,
120+
}}
121+
>
122+
<span
123+
style={{
124+
fontFamily: FONTS.mono,
125+
fontSize: compact ? "10px" : "11px",
126+
color: COLORS.textTertiary,
127+
textTransform: "uppercase",
128+
letterSpacing: "0.06em",
129+
}}
130+
>
131+
Agent is thinking
132+
</span>
133+
<span
134+
style={{
135+
fontFamily: FONTS.sans,
136+
fontSize: compact ? "12px" : "13px",
137+
color: COLORS.textSecondary,
138+
lineHeight: 1.5,
139+
}}
140+
>
141+
{PHASE_LABELS[phase]}
142+
</span>
143+
{showSkeleton && (
144+
<div style={{ display: "flex", flexDirection: "column", gap: compact ? "6px" : "8px", paddingTop: compact ? "2px" : "4px" }}>
145+
<SkeletonLine width={compact ? "94%" : "98%"} compact={compact} />
146+
<SkeletonLine width={compact ? "87%" : "92%"} compact={compact} />
147+
<SkeletonLine width={compact ? "72%" : "78%"} compact={compact} />
148+
</div>
149+
)}
150+
</div>
151+
)
152+
}

frontend/src/components/app/MessageList.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { FileAttachmentCard } from "@/components/ui/FileAttachmentCard"
1111
import { MessageActionsBar } from "@/components/ui/MessageActionsBar"
1212
import { extractMediaFromText } from "@/lib/mediaFromText"
1313
import { useStreamingDisplay } from "@/hooks/useStreamingDisplay"
14+
import { AgentThinkingStatus, useAgentThinkingPhase } from "@/components/app/AgentThinkingStatus"
1415

1516
/* ═══════════════════════════════════════════════════════════
1617
* MessageBubble — renders a single message.
@@ -25,12 +26,18 @@ const MessageBubble = memo(
2526
const isDark = useIsDark()
2627
const isUser = message.role === "user"
2728
const isStreaming = message.streamStatus === "streaming"
29+
const hasMeaningfulContent = Boolean((message.content || "").trim())
2830
const streamedDisplay = useStreamingDisplay(message.content ?? "", isStreaming)
2931
const shouldExtractMedia = message.role === "assistant" && !!message.content && !isStreaming
3032
const extracted = shouldExtractMedia ? extractMediaFromText(message.content, { idPrefix: message.id }) : null
3133
const displayText = message.content ? extracted?.text ?? message.content : isStreaming ? "" : "Thinking..."
3234
const streamingText = isStreaming ? streamedDisplay : displayText
3335
const media = extracted?.media ?? []
36+
const showThinkingCard = !isUser && isStreaming && !hasMeaningfulContent
37+
const thinkingPhase = useAgentThinkingPhase({
38+
active: isStreaming,
39+
hasStartedGenerating: hasMeaningfulContent,
40+
})
3441

3542
return (
3643
<div
@@ -90,7 +97,9 @@ const MessageBubble = memo(
9097
)}
9198

9299
<div style={{ wordBreak: "break-word", textAlign: isUser ? "right" : "left" }}>
93-
{message.role === "assistant" && !isStreaming ? (
100+
{showThinkingCard ? (
101+
<AgentThinkingStatus phase={thinkingPhase} compact />
102+
) : message.role === "assistant" && !isStreaming ? (
94103
<Markdownish content={displayText} variant="chat" />
95104
) : (
96105
<p

0 commit comments

Comments
 (0)