feat(FN-2948): merge fusion/fn-2948

- feat(FN-2948): complete Step 5 — apply review feedback and finalize delivery
- test(FN-2948): complete Step 3 — update insights section visibility coverage
- feat(FN-2948): complete Step 2 — enhance insight item readability
- feat(FN-2948): complete Step 1 — hide empty insight sections
- feat(FN-2939): merge fusion/fn-2939
- feat(FN-2946): merge fusion/fn-2946

Fusion-Task-Id: FN-2948
This commit is contained in:
Fusion
2026-04-29 02:41:27 -07:00
committed by gsxdsm
parent 1c4c08b4d9
commit f577b4a4f7
4 changed files with 257 additions and 110 deletions

View File

@@ -1002,6 +1002,10 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.chat-session-delete-btn {
opacity: 1;
}
.chat-message--assistant .chat-message-render-toggle { .chat-message--assistant .chat-message-render-toggle {
opacity: 1; opacity: 1;
} }

View File

@@ -213,14 +213,6 @@
padding: var(--space-md); padding: var(--space-md);
} }
/* Empty section placeholder */
.insights-empty-section {
padding: var(--space-lg);
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
/* Insights list */ /* Insights list */
.insights-list { .insights-list {
list-style: none; list-style: none;
@@ -236,6 +228,7 @@
padding: var(--space-md); padding: var(--space-md);
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: var(--radius-md); border-radius: var(--radius-md);
transition: border-color var(--transition-fast); transition: border-color var(--transition-fast);
} }
@@ -272,7 +265,7 @@
color: var(--text-muted); color: var(--text-muted);
line-height: 1.5; line-height: 1.5;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 3; -webkit-line-clamp: 4;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
@@ -304,8 +297,8 @@
} }
.insight-item-status--stale { .insight-item-status--stale {
background: color-mix(in srgb, var(--warning) 15%, transparent); background: color-mix(in srgb, var(--color-warning) 15%, transparent);
color: var(--warning); color: var(--color-warning);
} }
.insight-item-status--dismissed { .insight-item-status--dismissed {
@@ -387,33 +380,6 @@
min-height: 36px; min-height: 36px;
} }
.post-onboarding-recommendations {
flex-direction: column;
align-items: stretch;
gap: var(--space-sm);
padding: var(--space-md);
}
.post-onboarding-recommendations__main {
width: 100%;
}
.post-onboarding-recommendations__item {
align-items: flex-start;
flex-wrap: wrap;
}
.post-onboarding-recommendations .btn.btn-sm {
margin-left: auto;
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
}
.post-onboarding-recommendations__dismiss {
align-self: flex-end;
min-width: calc(var(--space-lg) * 2 + var(--space-xs));
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
}
/* InsightsView additional mobile refinements */ /* InsightsView additional mobile refinements */
.insights-view-actions .btn { .insights-view-actions .btn {
min-height: 36px; min-height: 36px;
@@ -428,9 +394,5 @@
font-size: 12px; font-size: 12px;
} }
/* Chat session delete button - always visible on mobile */
.chat-session-delete-btn {
opacity: 1;
}
} }

View File

@@ -164,72 +164,66 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
</div> </div>
<div className="insights-section-content"> <div className="insights-section-content">
{section.items.length === 0 ? ( <ul className="insights-list">
<div className="insights-empty-section" data-testid={`insights-empty-${section.category}`}> {section.items.map((insight) => {
<p>No insights in this category</p> const dismissState = dismissStates.get(insight.id);
</div> const createState = createTaskStates.get(insight.id);
) : ( const isDismissInFlight = dismissState?.running ?? false;
<ul className="insights-list"> const isCreateInFlight = createState?.running ?? false;
{section.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;
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}>
<div className="insight-item-header"> <div className="insight-item-header">
<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="btn btn-sm btn-icon"
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"
aria-label="Create task from this insight" aria-label="Create task from this insight"
data-testid={`create-task-${insight.id}`} data-testid={`create-task-${insight.id}`}
> >
{isCreateInFlight ? ( {isCreateInFlight ? (
<RefreshCw size={14} className="spin" /> <RefreshCw size={14} className="spin" />
) : ( ) : (
<Plus size={14} /> <Plus size={14} />
)} )}
</button> </button>
<button <button
className="btn btn-sm btn-icon" className="btn btn-sm btn-icon"
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"
aria-label="Dismiss this insight" aria-label="Dismiss this insight"
data-testid={`dismiss-${insight.id}`} data-testid={`dismiss-${insight.id}`}
> >
{isDismissInFlight ? ( {isDismissInFlight ? (
<RefreshCw size={14} className="spin" /> <RefreshCw size={14} className="spin" />
) : ( ) : (
<X size={14} /> <X size={14} />
)} )}
</button> </button>
</div>
</div> </div>
{insight.content && ( </div>
<p className="insight-item-content">{insight.content}</p> {insight.content && (
)} <p className="insight-item-content">{insight.content}</p>
<div className="insight-item-meta"> )}
<span className={`insight-item-status insight-item-status--${insight.status}`}> <div className="insight-item-meta">
{insight.status} <span className={`insight-item-status insight-item-status--${insight.status}`}>
{insight.status}
</span>
{insight.createdAt && (
<span className="insight-item-date">
<Clock size={12} />
{new Date(insight.createdAt).toLocaleDateString()}
</span> </span>
{insight.createdAt && ( )}
<span className="insight-item-date"> </div>
<Clock size={12} /> </li>
{new Date(insight.createdAt).toLocaleDateString()} );
</span> })}
)} </ul>
</div>
</li>
);
})}
</ul>
)}
</div> </div>
</section> </section>
); );
@@ -355,7 +349,7 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
</div> </div>
) : ( ) : (
<div className="insights-sections"> <div className="insights-sections">
{sections.map(renderSection)} {sections.filter((section) => section.items.length > 0).map(renderSection)}
</div> </div>
)} )}
</div> </div>

View File

@@ -120,8 +120,101 @@ describe("InsightsView", () => {
describe("rendering", () => { describe("rendering", () => {
it("should render all five section headings in expected order", () => { it("should render all five section headings in expected order", () => {
const populatedSections = [
{
...mockSections[0],
items: [
{
id: "INS-100",
projectId: "test",
title: "Features insight",
content: "Features content",
category: "features" as const,
status: "generated" as const,
fingerprint: "fp100",
provenance: { trigger: "manual" as const },
lastRunId: null,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
],
},
{
...mockSections[1],
items: [
{
id: "INS-101",
projectId: "test",
title: "Architecture insight",
content: "Architecture content",
category: "architecture" as const,
status: "generated" as const,
fingerprint: "fp101",
provenance: { trigger: "manual" as const },
lastRunId: null,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
],
},
{
...mockSections[2],
items: [
{
id: "INS-102",
projectId: "test",
title: "Competitive insight",
content: "Competitive content",
category: "competitive_analysis" as const,
status: "generated" as const,
fingerprint: "fp102",
provenance: { trigger: "manual" as const },
lastRunId: null,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
],
},
{
...mockSections[3],
items: [
{
id: "INS-103",
projectId: "test",
title: "Research insight",
content: "Research content",
category: "research" as const,
status: "generated" as const,
fingerprint: "fp103",
provenance: { trigger: "manual" as const },
lastRunId: null,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
],
},
{
...mockSections[4],
items: [
{
id: "INS-104",
projectId: "test",
title: "Trends insight",
content: "Trends content",
category: "trends" as const,
status: "generated" as const,
fingerprint: "fp104",
provenance: { trigger: "manual" as const },
lastRunId: null,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
],
},
];
mockUseInsights.mockReturnValue({ mockUseInsights.mockReturnValue({
sections: mockSections, sections: populatedSections,
loading: false, loading: false,
error: null, error: null,
latestRun: null, latestRun: null,
@@ -222,7 +315,7 @@ describe("InsightsView", () => {
expect(screen.getByText("No insights yet")).toBeInTheDocument(); expect(screen.getByText("No insights yet")).toBeInTheDocument();
}); });
it("should render per-section empty placeholder when specific section has no items", () => { it("should hide empty sections when specific section has no items", () => {
const sectionsWithOne = [ const sectionsWithOne = [
{ {
category: "features" as const, category: "features" as const,
@@ -267,8 +360,102 @@ describe("InsightsView", () => {
render(<InsightsView {...defaultProps} />); render(<InsightsView {...defaultProps} />);
expect(screen.getByTestId("insights-empty-architecture")).toBeInTheDocument(); expect(screen.queryByTestId("insights-section-architecture")).not.toBeInTheDocument();
expect(screen.getByTestId("insights-empty-architecture")).toHaveTextContent("No insights in this category"); expect(screen.getByTestId("insights-section-features")).toBeInTheDocument();
});
it("should only render sections that have items", () => {
const sectionsWithTwo = [
{
category: "features" as const,
label: "Features",
items: [
{
id: "INS-11",
projectId: "test",
title: "Features Insight",
content: "Content",
category: "features" as const,
status: "generated" as const,
fingerprint: "fp11",
provenance: { trigger: "manual" as const },
lastRunId: null,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
],
isLoading: false,
error: null,
},
{
category: "architecture" as const,
label: "Architecture",
items: [],
isLoading: false,
error: null,
},
{
category: "competitive_analysis" as const,
label: "Competitive Analysis",
items: [
{
id: "INS-12",
projectId: "test",
title: "Competitive Insight",
content: "Content",
category: "competitive_analysis" as const,
status: "generated" as const,
fingerprint: "fp12",
provenance: { trigger: "manual" as const },
lastRunId: null,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
],
isLoading: false,
error: null,
},
{
category: "research" as const,
label: "Research",
items: [],
isLoading: false,
error: null,
},
{
category: "trends" as const,
label: "Trends",
items: [],
isLoading: false,
error: null,
},
];
mockUseInsights.mockReturnValue({
sections: sectionsWithTwo,
loading: false,
error: null,
latestRun: null,
isRunInFlight: false,
runError: null,
refresh: vi.fn(),
runInsights: vi.fn(),
dismiss: vi.fn(),
createTask: vi.fn(),
dismissStates: new Map(),
createTaskStates: new Map(),
totalCount: 2,
dismissedCount: 0,
});
render(<InsightsView {...defaultProps} />);
expect(screen.getAllByTestId(/insights-section-/)).toHaveLength(2);
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();
}); });
it("should render status badge with correct CSS class for generated status", () => { it("should render status badge with correct CSS class for generated status", () => {