Improve diagnostics accordion
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user