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:
5
.changeset/insights-two-pane-layout.md
Normal file
5
.changeset/insights-two-pane-layout.md
Normal 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.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user