fix(dashboard): rework Insights view with two-pane layout

- Categories list in a left sidebar; insights for the selected category
  render in a scrollable right pane (mobile collapses sidebar to a
  horizontal chip rail and keeps the header on a single row).
- Removed -webkit-line-clamp on insight content so full text shows.
- Fixed scroll: added min-height: 0 to flex containers.
- Larger icons (20px) inside per-insight action buttons; close button
  switched off the invisible btn-icon styling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-29 16:25:30 -07:00
parent 118a03a34d
commit c6d67b9e1c
4 changed files with 275 additions and 70 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Insights view: two-pane layout (categories sidebar + scrollable detail), full insight content (no line-clamp), larger action icons, fixed scrolling, and mobile single-row header.

View File

@@ -3,6 +3,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
min-height: 0;
overflow: hidden; overflow: hidden;
} }
@@ -57,6 +58,10 @@
flex-shrink: 0; flex-shrink: 0;
} }
.insights-status-region:empty {
padding: 0;
}
.insights-status-message { .insights-status-message {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -93,6 +98,7 @@
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--color-error); color: var(--color-error);
font-size: 13px; font-size: 13px;
flex-shrink: 0;
} }
/* Run info */ /* Run info */
@@ -157,17 +163,100 @@
max-width: 400px; max-width: 400px;
} }
/* Sections container */ /* Two-pane body */
.insights-sections { .insights-body {
display: flex;
flex-direction: column;
gap: var(--space-lg);
padding: var(--space-lg);
overflow-y: auto;
flex: 1; flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
} }
/* Individual section */ /* Sidebar (categories) */
.insights-sidebar {
width: 240px;
flex-shrink: 0;
border-right: 1px solid var(--border);
background: var(--surface);
overflow-y: auto;
min-height: 0;
}
.insights-category-list {
list-style: none;
margin: 0;
padding: var(--space-sm);
display: flex;
flex-direction: column;
gap: 2px;
}
.insights-category-item {
width: 100%;
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-md);
color: var(--text);
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.insights-category-item:hover {
background: var(--surface-elevated);
}
.insights-category-item--active {
background: color-mix(in srgb, var(--accent) 12%, transparent);
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
color: var(--accent);
}
.insights-category-icon {
flex-shrink: 0;
color: var(--accent);
}
.insights-category-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.insights-category-count {
flex-shrink: 0;
font-size: 12px;
color: var(--text-muted);
background: var(--surface-elevated);
padding: 2px 8px;
border-radius: var(--radius-pill);
border: 1px solid var(--border);
}
.insights-category-item--active .insights-category-count {
background: var(--surface);
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
}
/* Detail pane */
.insights-detail {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: var(--space-lg);
}
/* Section (single, in detail pane) */
.insights-section { .insights-section {
background: var(--card); background: var(--card);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -259,15 +348,39 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* Borderless icon-prominent action buttons inside insight items */
.insight-item-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
border: none;
border-radius: var(--radius-md);
color: var(--text-muted);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
}
.insight-item-action-btn:hover:not(:disabled) {
background: var(--surface-elevated);
color: var(--accent);
}
.insight-item-action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.insight-item-content { .insight-item-content {
margin: var(--space-sm) 0 0; margin: var(--space-sm) 0 0;
font-size: 13px; font-size: 13px;
color: var(--text-muted); color: var(--text-muted);
line-height: 1.5; line-height: 1.5;
display: -webkit-box; white-space: pre-wrap;
-webkit-line-clamp: 4; word-break: break-word;
-webkit-box-orient: vertical;
overflow: hidden;
} }
.insight-item-meta { .insight-item-meta {
@@ -275,7 +388,7 @@
align-items: center; align-items: center;
gap: var(--space-md); gap: var(--space-md);
margin-top: var(--space-sm); margin-top: var(--space-sm);
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75); font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
} }
@@ -322,27 +435,69 @@
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
/* Mobile responsive */ /* Mobile responsive: stack panes vertically; sidebar becomes a horizontal scroller */
@media (max-width: 768px) { @media (max-width: 768px) {
.insights-view-header { .insights-view-header {
flex-wrap: wrap; flex-wrap: nowrap;
gap: var(--space-md); gap: var(--space-sm);
padding: var(--space-md); padding: var(--space-md);
} }
.insights-view-title {
min-width: 0;
flex: 1 1 auto;
}
.insights-view-title h2 { .insights-view-title h2 {
font-size: 16px; font-size: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.insights-view-actions { .insights-view-actions {
width: 100%; flex-shrink: 0;
justify-content: flex-end; justify-content: flex-end;
flex-wrap: wrap; flex-wrap: nowrap;
gap: var(--space-xs);
} }
.insights-sections { .insights-body {
flex-direction: column;
}
.insights-sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--border);
overflow-x: auto;
overflow-y: hidden;
flex-shrink: 0;
}
.insights-category-list {
flex-direction: row;
flex-wrap: nowrap;
padding: var(--space-sm);
gap: var(--space-xs);
}
.insights-category-list > li {
flex-shrink: 0;
}
.insights-category-item {
white-space: nowrap;
padding: var(--space-xs) var(--space-sm);
}
.insights-category-label {
overflow: visible;
text-overflow: clip;
}
.insights-detail {
padding: var(--space-md); padding: var(--space-md);
gap: var(--space-md);
} }
.insights-section-header { .insights-section-header {
@@ -375,12 +530,11 @@
align-self: flex-end; align-self: flex-end;
} }
.insight-item-actions .btn-icon { .insight-item-action-btn {
min-width: 36px; width: 40px;
min-height: 36px; height: 40px;
} }
/* InsightsView additional mobile refinements */
.insights-view-actions .btn { .insights-view-actions .btn {
min-height: 36px; min-height: 36px;
} }
@@ -393,6 +547,4 @@
.insights-view-count { .insights-view-count {
font-size: 12px; font-size: 12px;
} }
} }

View File

@@ -1,17 +1,11 @@
/** /**
* InsightsView - Dashboard component for displaying and managing project insights * InsightsView - Dashboard component for displaying and managing project insights
* *
* Features: * Two-pane layout: categories on the left, insights for the selected category on the right.
* - Displays insights grouped by all supported insight categories
* - Manual insight generation trigger
* - Per-insight dismiss action
* - Per-insight task creation action
* - Loading, error, and empty states
* - Accessible feedback for all actions
*/ */
import "./InsightsView.css"; import "./InsightsView.css";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
Sparkles, Sparkles,
RefreshCw, RefreshCw,
@@ -39,7 +33,6 @@ interface InsightsViewProps {
onCreateTask?: (title: string, description: string) => void; onCreateTask?: (title: string, description: string) => void;
} }
// Category icons mapping
const CATEGORY_ICONS: Record<InsightCategory, React.ComponentType<{ size?: number; className?: string }>> = { const CATEGORY_ICONS: Record<InsightCategory, React.ComponentType<{ size?: number; className?: string }>> = {
architecture: Building, architecture: Building,
quality: CheckCircle, quality: CheckCircle,
@@ -75,11 +68,33 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
totalCount, totalCount,
} = useInsights(projectId); } = useInsights(projectId);
// Track inline feedback messages
const [statusMessage, setStatusMessage] = useState<string | null>(null); const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [statusType, setStatusType] = useState<"success" | "error" | "info">("info"); const [statusType, setStatusType] = useState<"success" | "error" | "info">("info");
// Clear status message after delay const populatedSections = useMemo(
() => sections.filter((section) => section.items.length > 0),
[sections],
);
const [selectedCategory, setSelectedCategory] = useState<InsightCategory | null>(null);
// Keep selection valid as data changes; default to first populated section.
useEffect(() => {
if (populatedSections.length === 0) {
if (selectedCategory !== null) setSelectedCategory(null);
return;
}
const stillExists = selectedCategory && populatedSections.some((s) => s.category === selectedCategory);
if (!stillExists) {
setSelectedCategory(populatedSections[0].category);
}
}, [populatedSections, selectedCategory]);
const activeSection: InsightSection | undefined = useMemo(
() => populatedSections.find((s) => s.category === selectedCategory) ?? populatedSections[0],
[populatedSections, selectedCategory],
);
useEffect(() => { useEffect(() => {
if (statusMessage) { if (statusMessage) {
const timer = setTimeout(() => setStatusMessage(null), 5000); const timer = setTimeout(() => setStatusMessage(null), 5000);
@@ -87,7 +102,6 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
} }
}, [statusMessage]); }, [statusMessage]);
// Handle manual run
const handleRun = useCallback(async () => { const handleRun = useCallback(async () => {
try { try {
setStatusMessage("Generating insights..."); setStatusMessage("Generating insights...");
@@ -104,7 +118,6 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
} }
}, [runInsights, addToast]); }, [runInsights, addToast]);
// Handle dismiss
const handleDismiss = useCallback( const handleDismiss = useCallback(
async (id: string, title: string) => { async (id: string, title: string) => {
try { try {
@@ -124,7 +137,6 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
[dismiss, addToast], [dismiss, addToast],
); );
// Handle create task
const handleCreateTask = useCallback( const handleCreateTask = useCallback(
async (id: string, title: string) => { async (id: string, title: string) => {
try { try {
@@ -147,29 +159,53 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
[createTaskFromInsight, onCreateTask, addToast], [createTaskFromInsight, onCreateTask, addToast],
); );
// Render section const renderCategoryItem = (section: InsightSection) => {
const renderSection = (section: InsightSection) => {
const IconComponent = CATEGORY_ICONS[section.category] ?? Sparkles; const IconComponent = CATEGORY_ICONS[section.category] ?? Sparkles;
const isAnyActionInFlight = const isActive = activeSection?.category === section.category;
section.items.some((item) => dismissStates.get(item.id)?.running || createTaskStates.get(item.id)?.running) ?? false; return (
<li key={section.category}>
<button
type="button"
className={`insights-category-item${isActive ? " insights-category-item--active" : ""}`}
onClick={() => setSelectedCategory(section.category)}
aria-current={isActive ? "true" : undefined}
data-testid={`insights-category-${section.category}`}
>
<IconComponent size={16} className="insights-category-icon" />
<span className="insights-category-label">{section.label}</span>
<span className="insights-category-count">{section.items.length}</span>
</button>
</li>
);
};
const renderActiveInsights = () => {
if (!activeSection) return null;
const IconComponent = CATEGORY_ICONS[activeSection.category] ?? Sparkles;
return ( return (
<section key={section.category} className="insights-section" data-testid={`insights-section-${section.category}`}> <section
className="insights-section"
data-testid={`insights-section-${activeSection.category}`}
>
<div className="insights-section-header"> <div className="insights-section-header">
<div className="insights-section-title"> <div className="insights-section-title">
<IconComponent size={18} className="insights-section-icon" /> <IconComponent size={20} className="insights-section-icon" />
<h3>{section.label}</h3> <h3>{activeSection.label}</h3>
<span className="insights-section-count">{section.items.length}</span> <span className="insights-section-count">{activeSection.items.length}</span>
</div> </div>
</div> </div>
<div className="insights-section-content"> <div className="insights-section-content">
<ul className="insights-list"> <ul className="insights-list">
{section.items.map((insight) => { {activeSection.items.map((insight) => {
const dismissState = dismissStates.get(insight.id); const dismissState = dismissStates.get(insight.id);
const createState = createTaskStates.get(insight.id); const createState = createTaskStates.get(insight.id);
const isDismissInFlight = dismissState?.running ?? false; const isDismissInFlight = dismissState?.running ?? false;
const isCreateInFlight = createState?.running ?? false; const isCreateInFlight = createState?.running ?? false;
const isAnyActionInFlight = activeSection.items.some(
(item) => dismissStates.get(item.id)?.running || createTaskStates.get(item.id)?.running,
);
return ( return (
<li key={insight.id} className="insight-item" data-insight-id={insight.id}> <li key={insight.id} className="insight-item" data-insight-id={insight.id}>
@@ -177,7 +213,7 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
<h4 className="insight-item-title">{insight.title}</h4> <h4 className="insight-item-title">{insight.title}</h4>
<div className="insight-item-actions"> <div className="insight-item-actions">
<button <button
className="btn btn-sm btn-icon" className="insight-item-action-btn"
onClick={() => void handleCreateTask(insight.id, insight.title)} onClick={() => void handleCreateTask(insight.id, insight.title)}
disabled={isCreateInFlight || isAnyActionInFlight} disabled={isCreateInFlight || isAnyActionInFlight}
title="Create task from this insight" title="Create task from this insight"
@@ -185,13 +221,13 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
data-testid={`create-task-${insight.id}`} data-testid={`create-task-${insight.id}`}
> >
{isCreateInFlight ? ( {isCreateInFlight ? (
<RefreshCw size={14} className="spin" /> <RefreshCw size={20} className="spin" />
) : ( ) : (
<Plus size={14} /> <Plus size={20} />
)} )}
</button> </button>
<button <button
className="btn btn-sm btn-icon" className="insight-item-action-btn"
onClick={() => void handleDismiss(insight.id, insight.title)} onClick={() => void handleDismiss(insight.id, insight.title)}
disabled={isDismissInFlight || isAnyActionInFlight} disabled={isDismissInFlight || isAnyActionInFlight}
title="Dismiss this insight" title="Dismiss this insight"
@@ -199,9 +235,9 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
data-testid={`dismiss-${insight.id}`} data-testid={`dismiss-${insight.id}`}
> >
{isDismissInFlight ? ( {isDismissInFlight ? (
<RefreshCw size={14} className="spin" /> <RefreshCw size={20} className="spin" />
) : ( ) : (
<X size={14} /> <X size={20} />
)} )}
</button> </button>
</div> </div>
@@ -243,9 +279,10 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
<div className="insights-view-actions"> <div className="insights-view-actions">
{onClose && ( {onClose && (
<button <button
className="btn btn-icon insights-view-close" className="btn btn-sm insights-view-close"
onClick={onClose} onClick={onClose}
aria-label="Close insights view" aria-label="Close insights view"
title="Close"
> >
<X size={16} /> <X size={16} />
</button> </button>
@@ -282,7 +319,6 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
</div> </div>
</div> </div>
{/* Inline status region */}
<div <div
className="insights-status-region" className="insights-status-region"
aria-live="polite" aria-live="polite"
@@ -301,7 +337,6 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
)} )}
</div> </div>
{/* Run error callout */}
{runError && ( {runError && (
<div className="insights-error-callout" role="alert" data-testid="run-error"> <div className="insights-error-callout" role="alert" data-testid="run-error">
<AlertCircle size={16} /> <AlertCircle size={16} />
@@ -309,7 +344,6 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
</div> </div>
)} )}
{/* Latest run info */}
{latestRun && ( {latestRun && (
<div className="insights-run-info" data-testid="latest-run"> <div className="insights-run-info" data-testid="latest-run">
<span className="insights-run-status"> <span className="insights-run-status">
@@ -348,8 +382,15 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
</button> </button>
</div> </div>
) : ( ) : (
<div className="insights-sections"> <div className="insights-body">
{sections.filter((section) => section.items.length > 0).map(renderSection)} <aside className="insights-sidebar" aria-label="Insight categories">
<ul className="insights-category-list">
{populatedSections.map(renderCategoryItem)}
</ul>
</aside>
<div className="insights-detail">
{renderActiveInsights()}
</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -232,11 +232,14 @@ describe("InsightsView", () => {
render(<InsightsView {...defaultProps} />); render(<InsightsView {...defaultProps} />);
// Sidebar lists every populated category
expect(screen.getByTestId("insights-category-features")).toBeInTheDocument();
expect(screen.getByTestId("insights-category-architecture")).toBeInTheDocument();
expect(screen.getByTestId("insights-category-competitive_analysis")).toBeInTheDocument();
expect(screen.getByTestId("insights-category-research")).toBeInTheDocument();
expect(screen.getByTestId("insights-category-trends")).toBeInTheDocument();
// Detail pane shows the first populated section by default
expect(screen.getByTestId("insights-section-features")).toBeInTheDocument(); expect(screen.getByTestId("insights-section-features")).toBeInTheDocument();
expect(screen.getByTestId("insights-section-architecture")).toBeInTheDocument();
expect(screen.getByTestId("insights-section-competitive_analysis")).toBeInTheDocument();
expect(screen.getByTestId("insights-section-research")).toBeInTheDocument();
expect(screen.getByTestId("insights-section-trends")).toBeInTheDocument();
}); });
it("should render loading state", () => { it("should render loading state", () => {
@@ -450,12 +453,16 @@ describe("InsightsView", () => {
render(<InsightsView {...defaultProps} />); render(<InsightsView {...defaultProps} />);
expect(screen.getAllByTestId(/insights-section-/)).toHaveLength(2); // Sidebar lists exactly the two populated categories
expect(screen.getAllByTestId(/^insights-category-/)).toHaveLength(2);
expect(screen.getByTestId("insights-category-features")).toBeInTheDocument();
expect(screen.getByTestId("insights-category-competitive_analysis")).toBeInTheDocument();
expect(screen.queryByTestId("insights-category-architecture")).not.toBeInTheDocument();
expect(screen.queryByTestId("insights-category-research")).not.toBeInTheDocument();
expect(screen.queryByTestId("insights-category-trends")).not.toBeInTheDocument();
// Detail shows the first populated section (features)
expect(screen.getByTestId("insights-section-features")).toBeInTheDocument(); expect(screen.getByTestId("insights-section-features")).toBeInTheDocument();
expect(screen.getByTestId("insights-section-competitive_analysis")).toBeInTheDocument(); expect(screen.queryByTestId("insights-section-competitive_analysis")).not.toBeInTheDocument();
expect(screen.queryByTestId("insights-section-architecture")).not.toBeInTheDocument();
expect(screen.queryByTestId("insights-section-research")).not.toBeInTheDocument();
expect(screen.queryByTestId("insights-section-trends")).not.toBeInTheDocument();
}); });
it("should render status badge with correct CSS class for generated status", () => { it("should render status badge with correct CSS class for generated status", () => {