Skip to content

Commit 8d1bc67

Browse files
committed
feat: fixes and refactors
1 parent 2220f3f commit 8d1bc67

File tree

11 files changed

+518
-188
lines changed

11 files changed

+518
-188
lines changed

cli/src/chat.tsx

Lines changed: 162 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { Separator } from './components/separator'
2323
import { StatusIndicator, useHasStatus } from './components/status-indicator'
2424
import { SuggestionMenu } from './components/suggestion-menu'
2525
import { SLASH_COMMANDS } from './data/slash-commands'
26+
import { useAgentValidation } from './hooks/use-agent-validation'
2627
import { useAuthQuery, useLogoutMutation } from './hooks/use-auth-query'
2728
import { useClipboard } from './hooks/use-clipboard'
2829
import { useInputHistory } from './hooks/use-input-history'
@@ -39,11 +40,19 @@ import { getUserCredentials } from './utils/auth'
3940
import { LOGO } from './login/constants'
4041
import { createChatScrollAcceleration } from './utils/chat-scroll-accel'
4142
import { formatQueuedPreview } from './utils/helpers'
42-
import { loadLocalAgents } from './utils/local-agent-registry'
43+
import {
44+
loadLocalAgents,
45+
type LocalAgentInfo,
46+
} from './utils/local-agent-registry'
4347
import { logger } from './utils/logger'
4448
import { buildMessageTree } from './utils/message-tree-utils'
45-
import { chatThemes, createMarkdownPalette } from './utils/theme-system'
49+
import {
50+
chatThemes,
51+
createMarkdownPalette,
52+
type ChatTheme,
53+
} from './utils/theme-system'
4654
import { openFileAtPath } from './utils/open-file'
55+
import { formatValidationError } from './utils/validation-error-formatting'
4756

4857
import type { User } from './utils/auth'
4958
import type { ToolName } from '@codebuff/sdk'
@@ -155,6 +164,10 @@ export const App = ({
155164
const theme = chatThemes[themeName]
156165
const markdownPalette = useMemo(() => createMarkdownPalette(theme), [theme])
157166

167+
// Set up agent validation (manual trigger)
168+
const { validationErrors: liveValidationErrors, validate: validateAgents } =
169+
useAgentValidation(validationErrors)
170+
158171
const [exitWarning, setExitWarning] = useState<string | null>(null)
159172
const exitArmedRef = useRef(false)
160173
const exitWarningTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
@@ -245,127 +258,6 @@ export const App = ({
245258
'Codebuff can read and write files in this repository, and run terminal commands to help you build.',
246259
})
247260

248-
// Add validation errors if any exist
249-
if (validationErrors.length > 0) {
250-
const errorCount = validationErrors.length
251-
const errorHeader =
252-
errorCount === 1
253-
? '**⚠️ 1 agent has validation issues**'
254-
: `**⚠️ ${errorCount} agents have validation issues**`
255-
256-
// Add header
257-
blocks.push({
258-
type: 'text',
259-
content: errorHeader,
260-
marginTop: 1,
261-
})
262-
263-
// Add each error as a separate, nicely formatted block
264-
const agentInfoById = new Map(
265-
loadedAgentsData.agents.map((agent) => [agent.id, agent]),
266-
)
267-
268-
const normalizeRelativePath = (filePath: string): string => {
269-
const relativeToAgentsDir = path.relative(
270-
loadedAgentsData.agentsDir,
271-
filePath,
272-
)
273-
const normalized = relativeToAgentsDir.replace(/\\/g, '/')
274-
return `.agents/${normalized}`
275-
}
276-
277-
validationErrors.forEach((error, errorIndex) => {
278-
// Extract just the key error message, removing verbose schema details
279-
let message = error.message
280-
.replace(/Agent "[^"]+"\s*(?:\([^)]+\))?\s*:\s*/, '')
281-
.replace(/Schema validation failed:\s*/i, '')
282-
.trim()
283-
284-
// If message starts with JSON array, extract first error
285-
if (message.startsWith('[')) {
286-
try {
287-
const errors = JSON.parse(message)
288-
if (Array.isArray(errors) && errors.length > 0) {
289-
const firstError = errors[0]
290-
// Get the field path and message
291-
const field = firstError.path?.join('.') || 'field'
292-
message = `${field}: ${firstError.message}`
293-
}
294-
} catch {
295-
// Keep original message if parsing fails
296-
}
297-
}
298-
299-
// Clean up common validation messages to be more user-friendly
300-
message = message
301-
.replace(/Invalid input: expected (\w+), received (\w+)/i, 'Expected $1, got $2')
302-
.replace(/Agent ID must contain only lowercase letters, numbers, and hyphens/i, 'ID must be lowercase with hyphens only')
303-
.replace(/Cannot specify both (\w+) and (\w+)\..*/i, 'Cannot use both $1 and $2')
304-
305-
// Take first line only and limit length
306-
message = message.split('\n')[0]
307-
if (message.length > 80) {
308-
message = message.substring(0, 77) + '...'
309-
}
310-
311-
const agentId = error.id.replace(/_\d+$/, '')
312-
const agentInfo = agentInfoById.get(agentId)
313-
const relativePath = agentInfo
314-
? normalizeRelativePath(agentInfo.filePath)
315-
: null
316-
317-
const fieldMatch = message.match(/^([^:]+):\s*(.+)$/)
318-
const fieldName = fieldMatch ? fieldMatch[1] : null
319-
const errorBody = fieldMatch ? fieldMatch[2] : message
320-
321-
blocks.push({
322-
type: 'html',
323-
marginTop: errorIndex === 0 ? 0 : 0,
324-
render: ({ textColor }) => (
325-
<box style={{ flexDirection: 'row', gap: 1, alignItems: 'center' }}>
326-
<text wrap style={{ fg: textColor }}>
327-
<span attributes={TextAttributes.BOLD}>{agentId}</span>
328-
</text>
329-
{relativePath ? (
330-
<TerminalLink
331-
text={`(${relativePath})`}
332-
containerStyle={{
333-
width: 'auto',
334-
flexDirection: 'row',
335-
alignItems: 'center',
336-
}}
337-
formatLines={(text) => [text]}
338-
underlineOnHover
339-
onActivate={() => openFileAtPath(agentInfo.filePath)}
340-
/>
341-
) : null}
342-
</box>
343-
),
344-
})
345-
346-
blocks.push({
347-
type: 'html',
348-
marginBottom: 0,
349-
render: ({ textColor }) => (
350-
<text wrap style={{ fg: textColor, marginLeft: 2 }}>
351-
{fieldName ? (
352-
<>
353-
<span attributes={TextAttributes.ITALIC}>
354-
{`${fieldName}:`}
355-
</span>{' '}
356-
{errorBody}
357-
</>
358-
) : (
359-
errorBody
360-
)}
361-
</text>
362-
),
363-
})
364-
})
365-
366-
// No closing instruction to keep layout concise
367-
}
368-
369261
blocks.push({
370262
type: 'agent-list',
371263
id: agentListId,
@@ -517,6 +409,18 @@ export const App = ({
517409

518410
const { clipboardMessage } = useClipboard()
519411

412+
// Track main agent streaming start time for elapsed time display
413+
const [mainAgentStreamStartTime, setMainAgentStreamStartTime] =
414+
useState<number | null>(null)
415+
416+
// Debug log when stream start time changes
417+
useEffect(() => {
418+
logger.debug(
419+
{ streamStartTime: mainAgentStreamStartTime },
420+
'[STREAM TIMER] mainAgentStreamStartTime state changed',
421+
)
422+
}, [mainAgentStreamStartTime])
423+
520424
const agentRefsMap = useRef<Map<string, any>>(new Map())
521425
const hasAutoSubmittedRef = useRef(false)
522426
const activeSubagentsRef = useRef<Set<string>>(activeSubagents)
@@ -870,6 +774,8 @@ export const App = ({
870774
setCanProcessQueue,
871775
abortControllerRef,
872776
agentId,
777+
onBeforeMessageSend: validateAgents,
778+
setMainAgentStreamStartTime,
873779
})
874780

875781
sendMessageRef.current = sendMessage
@@ -890,7 +796,14 @@ export const App = ({
890796
return undefined
891797
}, [initialPrompt, agentMode])
892798

893-
const hasStatus = useHasStatus(isWaitingForResponse, clipboardMessage)
799+
// Show thinking indicator even after waiting ends if we're still streaming
800+
const showThinking = isStreaming && !isWaitingForResponse
801+
const hasStatus = useHasStatus(
802+
isWaitingForResponse,
803+
clipboardMessage,
804+
showThinking,
805+
mainAgentStreamStartTime,
806+
)
894807

895808
const handleSubmit = useCallback(() => {
896809
const trimmed = inputValue.trim()
@@ -1040,14 +953,136 @@ export const App = ({
1040953
const shouldShowStatusLine = Boolean(
1041954
exitWarning || hasStatus || shouldShowQueuePreview,
1042955
)
956+
957+
// Debug log status line conditions
958+
useEffect(() => {
959+
logger.debug(
960+
{
961+
shouldShowStatusLine,
962+
hasStatus,
963+
isWaitingForResponse,
964+
showThinking,
965+
mainAgentStreamStartTime,
966+
},
967+
'[STREAM TIMER] Status line conditions',
968+
)
969+
}, [
970+
shouldShowStatusLine,
971+
hasStatus,
972+
isWaitingForResponse,
973+
showThinking,
974+
mainAgentStreamStartTime,
975+
])
976+
1043977
const statusIndicatorNode = (
1044978
<StatusIndicator
1045979
isProcessing={isWaitingForResponse}
1046980
theme={theme}
1047981
clipboardMessage={clipboardMessage}
982+
showThinking={showThinking}
983+
streamStartTime={mainAgentStreamStartTime}
1048984
/>
1049985
)
1050986

987+
// Render validation banner
988+
const renderValidationBanner = () => {
989+
if (liveValidationErrors.length === 0) {
990+
return null
991+
}
992+
993+
const MAX_VISIBLE_ERRORS = 5
994+
const errorCount = liveValidationErrors.length
995+
const visibleErrors = liveValidationErrors.slice(0, MAX_VISIBLE_ERRORS)
996+
const hasMoreErrors = errorCount > MAX_VISIBLE_ERRORS
997+
998+
// Helper to normalize relative path
999+
const normalizeRelativePath = (filePath: string): string => {
1000+
if (!loadedAgentsData) return filePath
1001+
const relativeToAgentsDir = path.relative(
1002+
loadedAgentsData.agentsDir,
1003+
filePath,
1004+
)
1005+
const normalized = relativeToAgentsDir.replace(/\\/g, '/')
1006+
return `.agents/${normalized}`
1007+
}
1008+
1009+
// Get agent info by ID
1010+
const agentInfoById = new Map<string, LocalAgentInfo>(
1011+
(loadedAgentsData?.agents.map((agent) => [
1012+
agent.id,
1013+
agent as LocalAgentInfo,
1014+
]) || []) as [string, LocalAgentInfo][],
1015+
)
1016+
1017+
return (
1018+
<box
1019+
style={{
1020+
flexDirection: 'column',
1021+
paddingLeft: 1,
1022+
paddingRight: 1,
1023+
paddingTop: 1,
1024+
paddingBottom: 1,
1025+
backgroundColor: theme.panelBg,
1026+
border: true,
1027+
borderStyle: 'single',
1028+
borderColor: '#FFA500',
1029+
}}
1030+
>
1031+
{/* Header */}
1032+
<box
1033+
style={{
1034+
flexDirection: 'row',
1035+
alignItems: 'center',
1036+
paddingBottom: 0,
1037+
}}
1038+
>
1039+
<text wrap={false} style={{ fg: theme.messageAiText }}>
1040+
{`⚠️ ${errorCount === 1 ? '1 agent has validation issues' : `${errorCount} agents have validation issues`}`}
1041+
{hasMoreErrors &&
1042+
` (showing ${MAX_VISIBLE_ERRORS} of ${errorCount})`}
1043+
</text>
1044+
</box>
1045+
1046+
{/* Error list - build as single text with newlines */}
1047+
<text wrap style={{ fg: theme.messageAiText }}>
1048+
{visibleErrors.map((error, index) => {
1049+
const agentId = error.id.replace(/_\d+$/, '')
1050+
const agentInfo = agentInfoById.get(agentId)
1051+
const relativePath = agentInfo
1052+
? normalizeRelativePath(agentInfo.filePath)
1053+
: null
1054+
1055+
const { fieldName, message } = formatValidationError(error.message)
1056+
const errorMsg = fieldName ? `${fieldName}: ${message}` : message
1057+
const truncatedMsg = errorMsg.length > 68 ? errorMsg.substring(0, 65) + '...' : errorMsg
1058+
1059+
let output = index === 0 ? '\n' : '\n\n'
1060+
output += agentId
1061+
if (relativePath) {
1062+
output += ` (${relativePath})`
1063+
}
1064+
output += '\n ' + truncatedMsg
1065+
return output
1066+
}).join('')}
1067+
</text>
1068+
1069+
{/* Show count of additional errors */}
1070+
{hasMoreErrors && (
1071+
<box
1072+
style={{
1073+
flexDirection: 'row',
1074+
paddingTop: 0,
1075+
}}
1076+
>
1077+
<text wrap={false} style={{ fg: theme.statusSecondary }}>
1078+
{`... and ${errorCount - MAX_VISIBLE_ERRORS} more`}
1079+
</text>
1080+
</box>
1081+
)}
1082+
</box>
1083+
)
1084+
}
1085+
10511086
return (
10521087
<box
10531088
style={{
@@ -1058,6 +1093,9 @@ export const App = ({
10581093
flexGrow: 1,
10591094
}}
10601095
>
1096+
{/* Validation banner at the top */}
1097+
{renderValidationBanner()}
1098+
10611099
<box
10621100
style={{
10631101
flexDirection: 'column',

cli/src/components/message-block.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,7 @@ export const MessageBlock = ({
512512
)
513513
})()
514514
)}
515-
{isAi && isComplete && (completionTime || credits) && (
515+
{isAi && isComplete && credits && (
516516
<text
517517
wrap={false}
518518
attributes={TextAttributes.DIM}
@@ -523,8 +523,7 @@ export const MessageBlock = ({
523523
alignSelf: 'flex-start',
524524
}}
525525
>
526-
{completionTime}
527-
{credits && ` • ${credits} credits`}
526+
{credits} credits
528527
</text>
529528
)}
530529
</>

0 commit comments

Comments
 (0)