From fa8eacde531e6af3f236b87ed5c8e95a2cb3f61c Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 16 Nov 2025 22:32:00 +0000 Subject: [PATCH] Improve diagnostics accordion --- src/components/tool-call.tsx | 180 +++++++++++++++++++++++++++++ src/styles/messaging/tool-call.css | 159 +++++++++++++++++++++++++ 2 files changed, 339 insertions(+) diff --git a/src/components/tool-call.tsx b/src/components/tool-call.tsx index 074ceba7..e4b55523 100644 --- a/src/components/tool-call.tsx +++ b/src/components/tool-call.tsx @@ -141,6 +141,34 @@ function getRelativePath(path: string): string { const diffCapableTools = new Set(["edit", "patch"]) +interface LspRangePosition { + line?: number + character?: number +} + +interface LspRange { + start?: LspRangePosition +} + +interface LspDiagnostic { + message?: string + severity?: number + range?: LspRange +} + +interface DiagnosticEntry { + id: string + severity: number + tone: "error" | "warning" | "info" + label: string + icon: string + message: string + filePath: string + displayPath: string + line: number + column: number +} + interface DiffPayload { diffText: string filePath?: string @@ -180,6 +208,140 @@ function extractDiffPayload(toolName: string, state: ToolState): DiffPayload | n return { diffText, filePath } } +function normalizeDiagnosticPath(path: string) { + return path.replace(/\\/g, "/") +} + +function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] { + if (severity === 1) return "error" + if (severity === 2) return "warning" + return "info" +} + +function getSeverityMeta(tone: DiagnosticEntry["tone"]) { + if (tone === "error") return { label: "ERR", icon: "!", rank: 0 } + if (tone === "warning") return { label: "WARN", icon: "!", rank: 1 } + return { label: "INFO", icon: "i", rank: 2 } +} + +function extractDiagnostics(toolName: string, state: ToolState | undefined): DiagnosticEntry[] { + if (!state) return [] + const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state) + if (!supportsMetadata) return [] + + const metadata = (state.metadata || {}) as Record + const input = (state.input || {}) as Record + const diagnosticsMap = metadata?.diagnostics as Record | undefined + if (!diagnosticsMap) return [] + + const preferredPath = [ + input.filePath, + metadata.filePath, + metadata.filepath, + input.path, + ].find((value) => typeof value === "string" && value.length > 0) as string | undefined + + const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined + const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0) + if (candidateEntries.length === 0) return [] + + const prioritizedEntries = (() => { + if (!normalizedPreferred) return candidateEntries + const matched = candidateEntries.filter(([path]) => { + const normalized = normalizeDiagnosticPath(path) + if (normalized === normalizedPreferred) return true + if (normalized.endsWith(`/${normalizedPreferred}`)) return true + const normalizedBase = normalized.split("/").pop() + const preferredBase = normalizedPreferred.split("/").pop() + return normalizedBase && preferredBase ? normalizedBase === preferredBase : false + }) + return matched.length > 0 ? matched : candidateEntries + })() + + const entries: DiagnosticEntry[] = [] + for (const [pathKey, list] of prioritizedEntries) { + if (!Array.isArray(list)) continue + const normalizedPath = normalizeDiagnosticPath(pathKey) + for (let index = 0; index < list.length; index++) { + const diagnostic = list[index] + if (!diagnostic || typeof diagnostic.message !== "string") continue + const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined) + const severityMeta = getSeverityMeta(tone) + const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0 + const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0 + entries.push({ + id: `${normalizedPath}-${index}-${diagnostic.message}`, + severity: severityMeta.rank, + tone, + label: severityMeta.label, + icon: severityMeta.icon, + message: diagnostic.message, + filePath: normalizedPath, + displayPath: getRelativePath(normalizedPath), + line, + column, + }) + } + } + + return entries.sort((a, b) => a.severity - b.severity) +} + +function diagnosticFileName(entries: DiagnosticEntry[]) { + const first = entries[0] + return first ? first.displayPath : "" +} + +function renderDiagnosticsSection( + entries: DiagnosticEntry[], + expanded: boolean, + toggle: () => void, + toolIcon: string, + fileLabel: string, +) { + if (entries.length === 0) return null + return ( +
+ + +
+
+ + {(entry) => ( +
+ + {entry.icon} + {entry.label} + + + {entry.displayPath} + + :L{entry.line || "-"}:C{entry.column || "-"} + + + {entry.message} +
+ )} +
+
+
+
+
+ ) +} + export default function ToolCall(props: ToolCallProps) { const { preferences, setDiffViewMode } = useConfig() const { isDark } = useTheme() @@ -195,6 +357,13 @@ export default function ToolCall(props: ToolCallProps) { }) const [permissionSubmitting, setPermissionSubmitting] = createSignal(false) const [permissionError, setPermissionError] = createSignal(null) + const [diagnosticsExpanded, setDiagnosticsExpanded] = createSignal(true) + const diagnosticsEntries = createMemo(() => { + const tool = props.toolCall?.tool || "" + const state = props.toolCall?.state + if (!state) return [] + return extractDiagnostics(tool, state) + }) let scrollContainerRef: HTMLDivElement | undefined @@ -998,6 +1167,7 @@ export default function ToolCall(props: ToolCallProps) {
{renderToolBody()} + {renderError()} {renderPermissionBlock()} @@ -1010,6 +1180,16 @@ export default function ToolCall(props: ToolCallProps) {
+ + + {renderDiagnosticsSection( + diagnosticsEntries(), + diagnosticsExpanded(), + () => setDiagnosticsExpanded((prev) => !prev), + getToolIcon(toolName()), + diagnosticFileName(diagnosticsEntries()), + )} + ) } diff --git a/src/styles/messaging/tool-call.css b/src/styles/messaging/tool-call.css index c682f7b7..80da57c3 100644 --- a/src/styles/messaging/tool-call.css +++ b/src/styles/messaging/tool-call.css @@ -401,6 +401,165 @@ color: var(--text-muted); } +.tool-call-diagnostics { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-sm) var(--space-md); + border-top: 1px solid var(--border-base); + background-color: var(--surface-base); +} + +.tool-call-diagnostics-wrapper { + border-top: 1px solid var(--border-base); + background-color: var(--surface-base); + margin-top: var(--space-md); +} + +.tool-call-diagnostics-heading { + @apply flex items-center gap-2 p-2 w-full border-none cursor-pointer text-left; + font-family: var(--font-family-mono); + font-size: 13px; + color: var(--message-tool-border); + background-color: var(--surface-code); +} + +.tool-call-diagnostics-heading:hover { + background-color: var(--surface-hover); +} + +.tool-call-diagnostics-file { + @apply inline-flex items-center; + flex: 1; + font-size: 12px; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + justify-content: flex-end; +} + + +.tool-call-diagnostics-heading { + @apply flex items-center gap-2 p-2 w-full border-none cursor-pointer text-left; + font-family: var(--font-family-mono); + font-size: 13px; + color: var(--message-tool-border); + background-color: var(--surface-code); +} + +.tool-call-diagnostics-heading:hover { + background-color: var(--surface-hover); +} + +.tool-call-diagnostics-title { + font-size: 13px; +} + +.tool-call-diagnostics-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border-base); + font-size: 12px; +} + +.tool-call-diagnostics-file { + @apply inline-flex items-center; + flex: 1; + font-size: 12px; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tool-call-diagnostics-spacer { + flex: 1; +} + +.tool-call-diagnostics-caret { + font-size: 12px; + color: var(--text-muted); +} + +.tool-call-diagnostics { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-sm) var(--space-md) var(--space-sm) var(--space-md); + background-color: var(--surface-base); +} + +.tool-call-diagnostics-body { + display: flex; + flex-direction: column; + gap: var(--space-xs); + max-height: calc(4 * var(--tool-call-line-unit, 1.4em)); + overflow-y: scroll; + padding-right: 0; + margin-right: 0; + scrollbar-gutter: stable both-edges; + scrollbar-width: thin; +} + +.tool-call-diagnostic-row { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--space-sm); + font-size: var(--font-size-xs); + color: var(--text-primary); +} + +.tool-call-diagnostic-chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0 var(--space-sm); + min-height: 20px; + border-radius: var(--pill-radius); + font-size: 11px; + font-weight: var(--font-weight-medium); + letter-spacing: 0.02em; +} + +.tool-call-diagnostic-error { + background-color: var(--status-error-bg); + color: var(--status-error); +} + +.tool-call-diagnostic-warning { + background-color: var(--status-starting-bg); + color: var(--status-warning); +} + +.tool-call-diagnostic-info { + background-color: var(--badge-neutral-bg); + color: var(--badge-neutral-text); +} + +.tool-call-diagnostic-path { + font-family: var(--font-family-mono); + color: var(--text-secondary); + display: inline-flex; + align-items: baseline; + gap: var(--space-2xs); +} + +.tool-call-diagnostic-coords { + color: var(--text-muted); +} + +.tool-call-diagnostic-message { + flex: 1; + min-width: 200px; + color: var(--text-primary); +} + .tool-call-section pre { margin: 0; padding: 8px;