Skip to content

Commit f149758

Browse files
committed
feat: add message retry functionality and improve error handling
- Add pending retry state tracking and UI for interrupted messages - Implement stream inactivity timeout detection (15s) - Add retry button in status bar when messages fail - Surface network/connection errors to user - Add timestampNote field to messages for status indicators - Improve error handling in SDK client and run operations - Refactor shimmer-text component for better performance - Update tests to include new timestampNote field
1 parent a3be3d7 commit f149758

File tree

15 files changed

+538
-159
lines changed

15 files changed

+538
-159
lines changed

cli/src/chat.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,10 @@ export const Chat = ({
193193

194194
const { clipboardMessage } = useClipboard()
195195
const isConnected = useConnectionStatus()
196+
const isConnectedRef = useRef(isConnected)
197+
useEffect(() => {
198+
isConnectedRef.current = isConnected
199+
}, [isConnected])
196200
const mainAgentTimer = useElapsedTime()
197201
const timerStartTime = mainAgentTimer.startTime
198202

@@ -393,10 +397,11 @@ export const Chat = ({
393397
// Timer events are currently tracked but not used for UI updates
394398
// Future: Could be used for analytics or debugging
395399

396-
const { sendMessage, clearMessages } = useSendMessage({
397-
messages,
398-
allToggleIds,
399-
setMessages,
400+
const { sendMessage, clearMessages, pendingRetryCount, retryPendingMessages } =
401+
useSendMessage({
402+
messages,
403+
allToggleIds,
404+
setMessages,
400405
setFocusedAgentId,
401406
setInputFocused,
402407
inputRef,
@@ -421,10 +426,11 @@ export const Chat = ({
421426
setHasReceivedPlanResponse,
422427
lastMessageMode,
423428
setLastMessageMode,
424-
addSessionCredits,
425-
isQueuePausedRef,
426-
resumeQueue,
427-
})
429+
addSessionCredits,
430+
isQueuePausedRef,
431+
resumeQueue,
432+
isConnectedRef,
433+
})
428434

429435
sendMessageRef.current = sendMessage
430436

@@ -704,6 +710,8 @@ export const Chat = ({
704710
isConnected={isConnected}
705711
isAtBottom={isAtBottom}
706712
scrollToLatest={scrollToLatest}
713+
pendingRetryCount={pendingRetryCount}
714+
retryPendingMessages={retryPendingMessages}
707715
/>
708716
)}
709717

cli/src/components/__tests__/message-block.completion.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const baseProps = {
2828
isAi: true,
2929
isLoading: false,
3030
timestamp: '12:00',
31+
timestampNote: undefined,
3132
isComplete: false,
3233
completionTime: undefined,
3334
credits: undefined,

cli/src/components/__tests__/message-block.streaming.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const baseProps = {
2626
isAi: true,
2727
isComplete: false,
2828
timestamp: '12:00',
29+
timestampNote: undefined,
2930
completionTime: undefined,
3031
credits: undefined,
3132
textColor: theme.foreground,

cli/src/components/message-block.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ interface MessageBlockProps {
3030
isAi: boolean
3131
isLoading: boolean
3232
timestamp: string
33+
timestampNote?: string
3334
isComplete?: boolean
3435
completionTime?: string
3536
credits?: number
@@ -55,6 +56,7 @@ export const MessageBlock = memo((props: MessageBlockProps): ReactNode => {
5556
isAi,
5657
isLoading,
5758
timestamp,
59+
timestampNote,
5860
isComplete,
5961
completionTime,
6062
credits,
@@ -80,6 +82,9 @@ export const MessageBlock = memo((props: MessageBlockProps): ReactNode => {
8082

8183
const theme = useTheme()
8284
const resolvedTextColor = textColor ?? theme.foreground
85+
const timestampDisplay = timestampNote
86+
? `[${timestamp} · ${timestampNote}]`
87+
: `[${timestamp}]`
8388

8489
return (
8590
<>
@@ -94,7 +99,7 @@ export const MessageBlock = memo((props: MessageBlockProps): ReactNode => {
9499
alignSelf: 'flex-start',
95100
}}
96101
>
97-
{`[${timestamp}]`}
102+
{timestampDisplay}
98103
</text>
99104
)}
100105
{blocks ? (

cli/src/components/message-with-agents.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ export const MessageWithAgents = memo(
192192
isAi={isAi}
193193
isLoading={isLoading}
194194
timestamp={message.timestamp}
195+
timestampNote={message.timestampNote}
195196
isComplete={message.isComplete}
196197
completionTime={message.completionTime}
197198
credits={message.credits}
@@ -235,6 +236,7 @@ export const MessageWithAgents = memo(
235236
isAi={isAi}
236237
isLoading={isLoading}
237238
timestamp={message.timestamp}
239+
timestampNote={message.timestampNote}
238240
isComplete={message.isComplete}
239241
completionTime={message.completionTime}
240242
credits={message.credits}

cli/src/components/scroll-to-bottom-button.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,11 @@ export const ScrollToBottomButton = ({
2121
onMouseOver={() => setHovered(true)}
2222
onMouseOut={() => setHovered(false)}
2323
>
24-
<text>
25-
<span
26-
fg={theme.info}
27-
attributes={hovered ? TextAttributes.BOLD : TextAttributes.DIM}
28-
>
29-
{hovered ? '↓ Scroll to bottom ↓' : '↓'}
30-
</span>
24+
<text
25+
fg={theme.info}
26+
attributes={hovered ? TextAttributes.BOLD : TextAttributes.DIM}
27+
>
28+
{hovered ? '↓ Scroll to bottom ↓' : '↓'}
3129
</text>
3230
</Button>
3331
)

cli/src/components/shimmer-text.tsx

Lines changed: 46 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -144,60 +144,63 @@ export const ShimmerText = ({
144144
const chars = text.split('')
145145
const numChars = chars.length
146146

147+
if (numChars === 0) {
148+
return null
149+
}
150+
147151
useEffect(() => {
152+
if (numChars === 0) {
153+
return
154+
}
148155
const pulseInterval = setInterval(() => {
149156
setPulse((prev) => (prev + 1) % numChars)
150157
}, interval)
151158

152159
return () => clearInterval(pulseInterval)
153160
}, [interval, numChars])
154161

155-
const generateColors = (length: number, colorPalette: string[]): string[] => {
162+
const generateColors = (length: number, palette: string[]): string[] => {
156163
if (length === 0) return []
157-
if (colorPalette.length === 0) {
164+
if (palette.length === 0) {
158165
return Array.from({ length }, () => theme.muted)
159166
}
160-
if (colorPalette.length === 1) {
161-
return Array.from({ length }, () => colorPalette[0])
167+
if (palette.length === 1) {
168+
return Array.from({ length }, () => palette[0])
162169
}
163-
const generatedColors: string[] = []
170+
const generated: string[] = []
164171
for (let i = 0; i < length; i++) {
165172
const ratio = length === 1 ? 0 : i / (length - 1)
166173
const colorIndex = Math.min(
167-
colorPalette.length - 1,
168-
Math.floor(ratio * (colorPalette.length - 1)),
174+
palette.length - 1,
175+
Math.floor(ratio * (palette.length - 1)),
169176
)
170-
generatedColors.push(colorPalette[colorIndex])
177+
generated.push(palette[colorIndex])
171178
}
172-
return generatedColors
179+
return generated
173180
}
174181

175182
const palette = useMemo(() => {
176183
if (colors && colors.length > 0) {
177184
return colors
178185
}
179-
if (primaryColor) {
180-
const paletteSize = Math.max(8, Math.min(20, Math.ceil(numChars * 1.5)))
181-
return generatePaletteFromPrimary(primaryColor, paletteSize, theme.muted)
182-
}
183-
// Use theme shimmer color as default
184186
const paletteSize = Math.max(8, Math.min(20, Math.ceil(numChars * 1.5)))
185-
return generatePaletteFromPrimary(theme.info, paletteSize, theme.muted)
187+
const seedColor = primaryColor ?? theme.info
188+
return generatePaletteFromPrimary(seedColor, paletteSize, theme.muted)
186189
}, [colors, primaryColor, numChars, theme.info, theme.muted])
187190

188191
const generateAttributes = (length: number): number[] => {
189-
const attributes: number[] = []
192+
const attrs: number[] = []
190193
for (let i = 0; i < length; i++) {
191194
const ratio = length <= 1 ? 0 : i / (length - 1)
192195
if (ratio < 0.23) {
193-
attributes.push(TextAttributes.BOLD)
196+
attrs.push(TextAttributes.BOLD)
194197
} else if (ratio < 0.69) {
195-
attributes.push(TextAttributes.NONE)
198+
attrs.push(TextAttributes.NONE)
196199
} else {
197-
attributes.push(TextAttributes.DIM)
200+
attrs.push(TextAttributes.DIM)
198201
}
199202
}
200-
return attributes
203+
return attrs
201204
}
202205

203206
const generatedColors = useMemo(
@@ -207,39 +210,48 @@ export const ShimmerText = ({
207210
const attributes = useMemo(() => generateAttributes(numChars), [numChars])
208211

209212
const parts: { text: string; color: string; attr: number }[] = []
210-
let currentColor = generatedColors[0]
211-
let currentAttr = attributes[0]
212-
let currentText = ''
213+
let currentColor: string | undefined
214+
let currentAttr: number | undefined
215+
let buffer = ''
213216

214217
chars.forEach((char, index) => {
215218
const phase = (pulse - index + numChars) % numChars
216-
const charColor = generatedColors[phase]
217-
const charAttr = attributes[phase]
219+
const charColor = generatedColors[phase] ?? theme.muted
220+
const charAttr = attributes[phase] ?? TextAttributes.NONE
221+
222+
if (currentColor === undefined) {
223+
currentColor = charColor
224+
currentAttr = charAttr
225+
}
218226

219227
if (charColor === currentColor && charAttr === currentAttr) {
220-
currentText += char
228+
buffer += char
221229
} else {
222-
if (currentText) {
230+
if (buffer) {
223231
parts.push({
224-
text: currentText,
225-
color: currentColor,
226-
attr: currentAttr,
232+
text: buffer,
233+
color: currentColor ?? theme.muted,
234+
attr: currentAttr ?? TextAttributes.NONE,
227235
})
228236
}
229-
currentText = char
237+
buffer = char
230238
currentColor = charColor
231239
currentAttr = charAttr
232240
}
233241
})
234242

235-
if (currentText) {
236-
parts.push({ text: currentText, color: currentColor, attr: currentAttr })
243+
if (buffer) {
244+
parts.push({
245+
text: buffer,
246+
color: currentColor ?? theme.muted,
247+
attr: currentAttr ?? TextAttributes.NONE,
248+
})
237249
}
238250

239251
return (
240252
<>
241253
{parts.map((part, index) => (
242-
<span key={index} fg={part.color} attributes={part.attr}>
254+
<span key={`${part.color}-${index}`} fg={part.color} attributes={part.attr}>
243255
{part.text}
244256
</span>
245257
))}

0 commit comments

Comments
 (0)