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;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
@@ -57,6 +58,10 @@
flex-shrink: 0;
}
.insights-status-region:empty {
padding: 0;
}
.insights-status-message {
display: flex;
align-items: center;
@@ -93,6 +98,7 @@
border-radius: var(--radius-md);
color: var(--color-error);
font-size: 13px;
flex-shrink: 0;
}
/* Run info */
@@ -157,17 +163,100 @@
max-width: 400px;
}
/* Sections container */
.insights-sections {
display: flex;
flex-direction: column;
gap: var(--space-lg);
padding: var(--space-lg);
overflow-y: auto;
/* Two-pane body */
.insights-body {
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 {
background: var(--card);
border: 1px solid var(--border);
@@ -259,15 +348,39 @@
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 {
margin: var(--space-sm) 0 0;
font-size: 13px;
color: var(--text-muted);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: pre-wrap;
word-break: break-word;
}
.insight-item-meta {
@@ -275,7 +388,7 @@
align-items: center;
gap: var(--space-md);
margin-top: var(--space-sm);
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
font-size: 12px;
color: var(--text-muted);
}
@@ -322,27 +435,69 @@
animation: spin 1s linear infinite;
}
/* Mobile responsive */
/* Mobile responsive: stack panes vertically; sidebar becomes a horizontal scroller */
@media (max-width: 768px) {
.insights-view-header {
flex-wrap: wrap;
gap: var(--space-md);
flex-wrap: nowrap;
gap: var(--space-sm);
padding: var(--space-md);
}
.insights-view-title {
min-width: 0;
flex: 1 1 auto;
}
.insights-view-title h2 {
font-size: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.insights-view-actions {
width: 100%;
flex-shrink: 0;
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);
gap: var(--space-md);
}
.insights-section-header {
@@ -375,12 +530,11 @@
align-self: flex-end;
}
.insight-item-actions .btn-icon {
min-width: 36px;
min-height: 36px;
.insight-item-action-btn {
width: 40px;
height: 40px;
}
/* InsightsView additional mobile refinements */
.insights-view-actions .btn {
min-height: 36px;
}
@@ -393,6 +547,4 @@
.insights-view-count {
font-size: 12px;
}
}

View File

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

View File

@@ -232,11 +232,14 @@ describe("InsightsView", () => {
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-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", () => {
@@ -450,12 +453,16 @@ describe("InsightsView", () => {
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-competitive_analysis")).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();
expect(screen.queryByTestId("insights-section-competitive_analysis")).not.toBeInTheDocument();
});
it("should render status badge with correct CSS class for generated status", () => {