Skip to content

Commit e44904c

Browse files
wesmclaude
andauthored
fix: surface insight generation errors in the UI (#201)
## Summary - Send actual error details (e.g. "claude CLI not found", "gemini failed: exit status 1") to the client instead of the generic "X generation failed" message. Stderr dumps are stripped from the short message since they're already visible in the log stream. - Show task error details and execution logs in the main content pane instead of cramming them into the narrow sidebar. On error, the failed task is auto-selected so the user immediately sees an error banner and full log output. - Sidebar task items are now clickable to view their details in the main pane (both during generation and after errors). <img width="1914" height="392" alt="image" src="https://github.com/user-attachments/assets/bca10e27-7cf3-414b-b610-f14757d9bb8a" /> Closes #175 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e6100e0 commit e44904c

5 files changed

Lines changed: 273 additions & 37 deletions

File tree

frontend/src/lib/components/insights/InsightsPage.svelte

Lines changed: 168 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,11 @@
286286
<div
287287
class="task-item"
288288
class:task-error={task.status === "error"}
289+
class:selected={insights.selectedTaskId === task.clientId}
290+
role="button"
291+
tabindex="0"
292+
onclick={() => insights.selectTask(task.clientId)}
293+
onkeydown={(e) => { if (e.target === e.currentTarget && (e.key === "Enter" || e.key === " ")) insights.selectTask(task.clientId); }}
289294
>
290295
<div class="task-indicator">
291296
{#if task.status === "generating"}
@@ -311,30 +316,18 @@
311316
{:else}
312317
<span class="task-phase">{task.phase}</span>
313318
{/if}
314-
{#if task.logs.length > 0}
315-
<div
316-
class="task-logs"
317-
role="log"
318-
aria-live="polite"
319-
>
320-
{#each task.logs as entry}
321-
<div
322-
class="task-log-line"
323-
class:log-stderr={entry.stream === "stderr"}
324-
>
325-
<span class="task-log-stream">{entry.stream}</span>
326-
<span class="task-log-text">{entry.line}</span>
327-
</div>
328-
{/each}
329-
</div>
330-
{/if}
331319
</div>
332320
<span class="task-agent">{task.agent}</span>
333321
<button
334322
class="task-dismiss"
335-
onclick={() => task.status === "error"
336-
? insights.dismissTask(task.clientId)
337-
: insights.cancelTask(task.clientId)}
323+
onclick={(e) => {
324+
e.stopPropagation();
325+
if (task.status === "error") {
326+
insights.dismissTask(task.clientId);
327+
} else {
328+
insights.cancelTask(task.clientId);
329+
}
330+
}}
338331
title={task.status === "error" ? "Dismiss" : "Cancel"}
339332
>
340333
<svg width="8" height="8" viewBox="0 0 16 16" fill="currentColor">
@@ -400,7 +393,80 @@
400393
</div>
401394

402395
<main class="content-panel">
403-
{#if insights.selectedItem}
396+
{#if insights.selectedTask}
397+
{@const task = insights.selectedTask}
398+
<div class="reading-area">
399+
<header class="insight-header">
400+
<div class="header-top">
401+
<span
402+
class="header-badge"
403+
class:badge-red={task.status === "error"}
404+
class:badge-blue={task.status !== "error"}
405+
>
406+
{task.status === "error" ? "Error" : "Generating"}
407+
</span>
408+
<span class="header-date">
409+
{typeShort(task.type, task.dateFrom, task.dateTo)}
410+
{formatDateRange(task.dateFrom, task.dateTo)}
411+
</span>
412+
<button
413+
class="delete-btn"
414+
onclick={() => task.status === "error"
415+
? insights.dismissTask(task.clientId)
416+
: insights.cancelTask(task.clientId)}
417+
title={task.status === "error" ? "Dismiss" : "Cancel"}
418+
>
419+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
420+
<path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"/>
421+
</svg>
422+
</button>
423+
</div>
424+
<div class="header-details">
425+
{#if task.project}
426+
<span class="detail-chip">{task.project}</span>
427+
{:else}
428+
<span class="detail-chip muted">global</span>
429+
{/if}
430+
<span class="detail-text">{task.agent}</span>
431+
</div>
432+
</header>
433+
{#if task.status === "error" && task.error}
434+
<div class="task-error-banner">
435+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
436+
<path d="M8.982 1.566a1.13 1.13 0 00-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 01-1.1 0L7.1 5.995A.905.905 0 018 5zm.002 6a1 1 0 110 2 1 1 0 010-2z"/>
437+
</svg>
438+
<span>{task.error}</span>
439+
</div>
440+
{/if}
441+
{#if task.logs.length > 0}
442+
<div class="task-detail-logs" role="log">
443+
<div class="task-detail-logs-header">
444+
Execution Log
445+
<span class="log-count">{task.logs.length} lines</span>
446+
</div>
447+
<div class="task-detail-logs-body">
448+
{#each task.logs as entry}
449+
<div
450+
class="task-log-line"
451+
class:log-stderr={entry.stream === "stderr"}
452+
>
453+
<span class="task-log-stream">{entry.stream}</span>
454+
<span class="task-log-text">{entry.line}</span>
455+
</div>
456+
{/each}
457+
</div>
458+
</div>
459+
{:else if task.status === "generating"}
460+
<div class="content-generating" style="margin-top: 48px">
461+
<div class="gen-orbit">
462+
<span class="orbit-ring"></span>
463+
<span class="orbit-dot"></span>
464+
</div>
465+
<span class="gen-label">Waiting for {task.agent}...</span>
466+
</div>
467+
{/if}
468+
</div>
469+
{:else if insights.selectedItem}
404470
<div class="reading-area">
405471
<header class="insight-header">
406472
<div class="header-top">
@@ -736,6 +802,24 @@
736802
min-height: 42px;
737803
padding: 8px 14px 10px;
738804
overflow: hidden;
805+
width: 100%;
806+
text-align: left;
807+
border-left: 2px solid transparent;
808+
transition: background 0.1s;
809+
cursor: pointer;
810+
}
811+
812+
.task-item:hover {
813+
background: var(--bg-surface-hover);
814+
}
815+
816+
.task-item.selected {
817+
background: var(--bg-surface-hover);
818+
border-left-color: var(--accent-blue);
819+
}
820+
821+
.task-item.selected.task-error {
822+
border-left-color: var(--accent-red);
739823
}
740824
741825
.task-error {
@@ -827,24 +911,72 @@
827911
word-break: break-word;
828912
}
829913
830-
.task-logs {
831-
width: 100%;
832-
max-height: 132px;
833-
overflow-y: auto;
914+
/* ── Task Detail (main pane) ── */
915+
.task-error-banner {
916+
display: flex;
917+
align-items: flex-start;
918+
gap: 10px;
919+
padding: 12px 16px;
920+
border-radius: var(--radius-md);
921+
background: color-mix(
922+
in srgb,
923+
var(--accent-red) 8%,
924+
var(--bg-inset)
925+
);
926+
border: 1px solid color-mix(
927+
in srgb,
928+
var(--accent-red) 25%,
929+
var(--border-muted)
930+
);
931+
color: var(--accent-red);
932+
font-size: 13px;
933+
line-height: 1.5;
934+
margin-bottom: 20px;
935+
}
936+
937+
.task-error-banner svg {
938+
flex-shrink: 0;
834939
margin-top: 2px;
835-
padding: 4px 6px;
940+
opacity: 0.8;
941+
}
942+
943+
.task-detail-logs {
836944
border: 1px solid var(--border-muted);
837-
border-radius: 6px;
945+
border-radius: var(--radius-md);
946+
overflow: hidden;
947+
}
948+
949+
.task-detail-logs-header {
950+
display: flex;
951+
align-items: center;
952+
justify-content: space-between;
953+
padding: 8px 14px;
838954
background: var(--bg-inset);
955+
border-bottom: 1px solid var(--border-muted);
956+
font-size: 11px;
957+
font-weight: 600;
958+
color: var(--text-secondary);
959+
}
960+
961+
.log-count {
962+
font-weight: 400;
963+
color: var(--text-muted);
964+
font-variant-numeric: tabular-nums;
965+
}
966+
967+
.task-detail-logs-body {
968+
max-height: 50vh;
969+
overflow-y: auto;
970+
padding: 8px 14px;
839971
font-family: var(--font-mono);
840-
font-size: 10px;
841-
line-height: 1.4;
972+
font-size: 11px;
973+
line-height: 1.5;
842974
}
843975
844976
.task-log-line {
845977
display: grid;
846-
grid-template-columns: 42px 1fr;
847-
gap: 6px;
978+
grid-template-columns: 48px 1fr;
979+
gap: 8px;
848980
color: var(--text-secondary);
849981
white-space: pre-wrap;
850982
word-break: break-word;
@@ -853,6 +985,7 @@
853985
.task-log-stream {
854986
text-transform: uppercase;
855987
color: var(--text-muted);
988+
font-size: 10px;
856989
}
857990
858991
.task-log-text {
@@ -1078,6 +1211,10 @@
10781211
background: var(--accent-purple);
10791212
}
10801213
1214+
.badge-red {
1215+
background: var(--accent-red);
1216+
}
1217+
10811218
.header-date {
10821219
font-size: 15px;
10831220
font-weight: 600;

frontend/src/lib/stores/insights.svelte.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ class InsightsStore {
4343
agent: AgentName = $state("claude");
4444
items: Insight[] = $state([]);
4545
selectedId: number | null = $state(null);
46+
selectedTaskId: string | null = $state(null);
4647
loading = $state(false);
4748
promptText: string = $state("");
4849
tasks: InsightTask[] = $state([]);
@@ -56,6 +57,13 @@ class InsightsStore {
5657
);
5758
}
5859

60+
get selectedTask(): InsightTask | undefined {
61+
if (this.selectedTaskId === null) return undefined;
62+
return this.tasks.find(
63+
(t) => t.clientId === this.selectedTaskId,
64+
);
65+
}
66+
5967
get generatingCount(): number {
6068
return this.tasks.filter(
6169
(t) => t.status === "generating",
@@ -111,6 +119,12 @@ class InsightsStore {
111119

112120
select(id: number) {
113121
this.selectedId = id;
122+
this.selectedTaskId = null;
123+
}
124+
125+
selectTask(clientId: string) {
126+
this.selectedTaskId = clientId;
127+
this.selectedId = null;
114128
}
115129

116130
generate() {
@@ -205,6 +219,8 @@ class InsightsStore {
205219
? { ...t, status: "error" as const, error: msg }
206220
: t,
207221
);
222+
this.selectedTaskId = clientId;
223+
this.selectedId = null;
208224
});
209225
}
210226

@@ -217,6 +233,9 @@ class InsightsStore {
217233
this.tasks = this.tasks.filter(
218234
(t) => t.clientId !== clientId,
219235
);
236+
if (this.selectedTaskId === clientId) {
237+
this.selectedTaskId = null;
238+
}
220239
}
221240

222241
async deleteItem(id: number) {

frontend/src/lib/stores/insights.test.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ beforeEach(() => {
4444
vi.clearAllMocks();
4545
insights.items = [];
4646
insights.selectedId = null;
47+
insights.selectedTaskId = null;
4748
insights.loading = false;
4849
insights.tasks = [];
4950
insights.promptText = "";
@@ -185,9 +186,20 @@ describe("setProject", () => {
185186
});
186187

187188
describe("select", () => {
188-
it("sets selectedId", () => {
189+
it("sets selectedId and clears selectedTaskId", () => {
190+
insights.selectedTaskId = "some-task";
189191
insights.select(42);
190192
expect(insights.selectedId).toBe(42);
193+
expect(insights.selectedTaskId).toBeNull();
194+
});
195+
});
196+
197+
describe("selectTask", () => {
198+
it("sets selectedTaskId and clears selectedId", () => {
199+
insights.selectedId = 42;
200+
insights.selectTask("task-123");
201+
expect(insights.selectedTaskId).toBe("task-123");
202+
expect(insights.selectedId).toBeNull();
191203
});
192204
});
193205

@@ -268,7 +280,7 @@ describe("generate (multi-task)", () => {
268280
expect(insights.items[0]).toEqual(s2);
269281
});
270282

271-
it("sets error on task failure", async () => {
283+
it("sets error on task failure and selects task", async () => {
272284
const mockHandle = {
273285
abort: vi.fn(),
274286
done: Promise.reject(new Error("CLI not found")),
@@ -283,6 +295,10 @@ describe("generate (multi-task)", () => {
283295
expect(insights.tasks).toHaveLength(1);
284296
expect(insights.tasks[0]!.status).toBe("error");
285297
expect(insights.tasks[0]!.error).toBe("CLI not found");
298+
expect(insights.selectedTaskId).toBe(
299+
insights.tasks[0]!.clientId,
300+
);
301+
expect(insights.selectedId).toBeNull();
286302
});
287303

288304
it("captures streaming logs per task", async () => {

internal/server/insights.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,22 @@ type generateInsightRequest struct {
133133
Agent string `json:"agent"`
134134
}
135135

136-
func insightGenerateClientMessage(agent string) string {
137-
return fmt.Sprintf("%s generation failed", agent)
136+
func insightGenerateClientMessage(
137+
agent string, err error,
138+
) string {
139+
if err == nil {
140+
return fmt.Sprintf("%s generation failed", agent)
141+
}
142+
msg := err.Error()
143+
// Strip stderr dump after newline for the short
144+
// client message; full details are in the log stream.
145+
if idx := strings.Index(msg, "\nstderr:"); idx > 0 {
146+
msg = msg[:idx]
147+
}
148+
if idx := strings.Index(msg, "\nraw:"); idx > 0 {
149+
msg = msg[:idx]
150+
}
151+
return msg
138152
}
139153

140154
func (s *Server) handleGenerateInsight(
@@ -350,7 +364,9 @@ func (s *Server) handleGenerateInsight(
350364
if err != nil {
351365
log.Printf("insight generate error: %v", err)
352366
sendJSON("error", map[string]string{
353-
"message": insightGenerateClientMessage(req.Agent),
367+
"message": insightGenerateClientMessage(
368+
req.Agent, err,
369+
),
354370
})
355371
return
356372
}

0 commit comments

Comments
 (0)