Improve diagnostics accordion

This commit is contained in:
Shantur Rathore
2025-11-16 22:32:00 +00:00
parent 742c2d2c29
commit fa8eacde53
2 changed files with 339 additions and 0 deletions

View File

@@ -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<string, unknown>
const input = (state.input || {}) as Record<string, unknown>
const diagnosticsMap = metadata?.diagnostics as Record<string, LspDiagnostic[] | undefined> | 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 (
<div class="tool-call-diagnostics-wrapper">
<button
type="button"
class="tool-call-diagnostics-heading"
aria-expanded={expanded}
onClick={toggle}
>
<span class="tool-call-icon" aria-hidden="true">
{expanded ? "▼" : "▶"}
</span>
<span class="tool-call-emoji" aria-hidden="true">🛠</span>
<span class="tool-call-summary">Diagnostics</span>
<span class="tool-call-diagnostics-file" title={fileLabel}>{fileLabel}</span>
</button>
<Show when={expanded}>
<div class="tool-call-diagnostics" role="region" aria-label="Diagnostics">
<div class="tool-call-diagnostics-body" role="list">
<For each={entries}>
{(entry) => (
<div class="tool-call-diagnostic-row" role="listitem">
<span class={`tool-call-diagnostic-chip tool-call-diagnostic-${entry.tone}`}>
<span class="tool-call-diagnostic-chip-icon">{entry.icon}</span>
<span>{entry.label}</span>
</span>
<span class="tool-call-diagnostic-path" title={entry.filePath}>
{entry.displayPath}
<span class="tool-call-diagnostic-coords">
:L{entry.line || "-"}:C{entry.column || "-"}
</span>
</span>
<span class="tool-call-diagnostic-message">{entry.message}</span>
</div>
)}
</For>
</div>
</div>
</Show>
</div>
)
}
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<string | null>(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) {
<Show when={expanded()}>
<div class="tool-call-details">
{renderToolBody()}
{renderError()}
{renderPermissionBlock()}
@@ -1010,6 +1180,16 @@ export default function ToolCall(props: ToolCallProps) {
</Show>
</div>
</Show>
<Show when={diagnosticsEntries().length}>
{renderDiagnosticsSection(
diagnosticsEntries(),
diagnosticsExpanded(),
() => setDiagnosticsExpanded((prev) => !prev),
getToolIcon(toolName()),
diagnosticFileName(diagnosticsEntries()),
)}
</Show>
</div>
)
}

View File

@@ -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;