Skip to content

Commit b1dbdde

Browse files
simple-agent-manager[bot]raphaeltmclaude
authored
refactor(trial): split TryDiscovery.tsx into focused modules (raphaeltm#771)
* task: activate split-try-discovery-tsx * refactor(trial): split TryDiscovery.tsx into focused modules Extract 910-line monolith into 6 focused files: - hooks/useTrialEvents.ts — SSE lifecycle hook - lib/trial-view-model.ts — deriveView, buildFeed, types - lib/trial-utils.ts — cleanActivityText, extractRepoName, eventDedupKey - components/trial/DiscoveryCards.tsx — all card components - components/trial/DiscoveryHeader.tsx — header + connection badge - pages/TryDiscovery.tsx — page shell (188 lines) Re-exports buildFeed and eventDedupKey from TryDiscovery.tsx for backward compatibility with existing tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: sort re-exports for simple-import-sort Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * task: archive split-try-discovery-tsx Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: trigger CI with updated PR body Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Raphaël Titsworth-Morin <raphael@raphaeltm.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6e883d1 commit b1dbdde

7 files changed

Lines changed: 858 additions & 755 deletions

File tree

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
/**
2+
* Presentational card components for the trial discovery feed.
3+
*
4+
* Includes event cards, knowledge groups, idea cards, agent activity,
5+
* stage skeleton, and terminal error panel.
6+
*
7+
* Extracted from TryDiscovery.tsx.
8+
*/
9+
import type {
10+
TrialAgentActivityEvent,
11+
TrialErrorEvent,
12+
TrialEvent,
13+
TrialIdeaEvent,
14+
TrialKnowledgeEvent,
15+
} from '@simple-agent-manager/shared';
16+
import { Alert } from '@simple-agent-manager/ui';
17+
import { BookOpen, Brain, Lightbulb, Terminal, Wrench } from 'lucide-react';
18+
import type { ReactNode } from 'react';
19+
import { useState } from 'react';
20+
import { Link } from 'react-router';
21+
22+
import { trialErrorMessage } from '../../lib/trial-api';
23+
import { friendlyStageLabel, STAGE_TIMELINE } from '../../lib/trial-ui-config';
24+
import { cleanActivityText, extractRepoName } from '../../lib/trial-utils';
25+
26+
// ---------------------------------------------------------------------------
27+
// EventCard — dispatches to the correct card by event type
28+
// ---------------------------------------------------------------------------
29+
30+
export function EventCard({
31+
event,
32+
}: {
33+
event: Exclude<TrialEvent, TrialKnowledgeEvent | TrialErrorEvent>;
34+
}) {
35+
switch (event.type) {
36+
case 'trial.started':
37+
return (
38+
<Card tone="neutral" icon="◎" title={`Exploring ${extractRepoName(event.repoUrl)}`}>
39+
<p className="text-xs text-fg-muted">Trial id: <code className="font-mono text-[11px]">{event.trialId}</code></p>
40+
</Card>
41+
);
42+
case 'trial.progress':
43+
return (
44+
<Card tone="neutral" icon="▸" title={friendlyStageLabel(event.stage)}>
45+
{event.progress !== undefined ? (
46+
<p className="text-xs text-fg-muted">{Math.round(event.progress * 100)}% complete</p>
47+
) : null}
48+
</Card>
49+
);
50+
case 'trial.idea':
51+
return <IdeaCard event={event} />;
52+
case 'trial.ready':
53+
return (
54+
<Card tone="success" icon={<Terminal className="w-5 h-5" />} title="Environment ready">
55+
<p className="text-xs text-fg-muted">
56+
Your development environment is configured. An agent is now analyzing the
57+
repository to build a knowledge graph and suggest next steps&hellip;
58+
</p>
59+
</Card>
60+
);
61+
default:
62+
return null;
63+
}
64+
}
65+
66+
// ---------------------------------------------------------------------------
67+
// Card — reusable wrapper with tone variants
68+
// ---------------------------------------------------------------------------
69+
70+
export function Card({
71+
tone,
72+
icon,
73+
title,
74+
children,
75+
}: {
76+
tone: 'neutral' | 'success' | 'info';
77+
icon: ReactNode;
78+
title: string;
79+
children?: ReactNode;
80+
}) {
81+
const toneClasses =
82+
tone === 'success'
83+
? 'border-success/30 bg-success-tint/50'
84+
: tone === 'info'
85+
? 'border-info/30 bg-info-tint/50'
86+
: 'border-border-default bg-surface';
87+
return (
88+
<article className={`rounded-md border p-3 sm:p-4 ${toneClasses}`}>
89+
<div className="flex items-start gap-3">
90+
<span aria-hidden className="text-lg leading-none shrink-0">
91+
{icon}
92+
</span>
93+
<div className="min-w-0 flex-1">
94+
<h3 className="text-sm font-semibold truncate">{title}</h3>
95+
{children ? <div className="mt-1">{children}</div> : null}
96+
</div>
97+
</div>
98+
</article>
99+
);
100+
}
101+
102+
// ---------------------------------------------------------------------------
103+
// KnowledgeGroupCard
104+
// ---------------------------------------------------------------------------
105+
106+
/**
107+
* Single grouped card for a burst of consecutive `trial.knowledge` events.
108+
* Shows the first observation by default; the rest collapse behind a
109+
* "+N more" toggle.
110+
*/
111+
export function KnowledgeGroupCard({ items }: { items: TrialKnowledgeEvent[] }) {
112+
const [expanded, setExpanded] = useState(false);
113+
const head = items[0];
114+
const rest = items.slice(1);
115+
if (!head) return null;
116+
117+
return (
118+
<article
119+
data-testid="trial-knowledge-group"
120+
className="rounded-md border border-border-default bg-surface p-3 sm:p-4"
121+
>
122+
<div className="flex items-start gap-3">
123+
<span aria-hidden className="shrink-0 inline-flex items-center justify-center w-7 h-7 rounded-full bg-accent/10 text-accent">
124+
<BookOpen className="w-4 h-4" />
125+
</span>
126+
<div className="min-w-0 flex-1">
127+
<h3 className="text-sm font-semibold truncate">{head.entity}</h3>
128+
<p className="mt-1 text-xs text-fg-muted">{head.observation}</p>
129+
{rest.length > 0 ? (
130+
<>
131+
<button
132+
type="button"
133+
onClick={() => setExpanded((v) => !v)}
134+
aria-expanded={expanded}
135+
data-testid="trial-knowledge-toggle"
136+
className="mt-2 inline-flex items-center text-xs font-medium text-accent hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded min-h-11 -mx-1 px-1"
137+
>
138+
{expanded ? 'Show less' : `+${rest.length} more`}
139+
</button>
140+
{expanded ? (
141+
<ul className="mt-2 flex flex-col gap-2 border-t border-border-default pt-2">
142+
{rest.map((item, idx) => (
143+
<li key={`${idx}-${item.entity}`} className="text-xs">
144+
<span className="font-semibold text-fg-primary">{item.entity}: </span>
145+
<span className="text-fg-muted">{item.observation}</span>
146+
</li>
147+
))}
148+
</ul>
149+
) : null}
150+
</>
151+
) : null}
152+
</div>
153+
</div>
154+
</article>
155+
);
156+
}
157+
158+
// ---------------------------------------------------------------------------
159+
// IdeaCard
160+
// ---------------------------------------------------------------------------
161+
162+
export function IdeaCard({ event }: { event: TrialIdeaEvent }) {
163+
return (
164+
<article className="rounded-md border border-info/30 bg-info-tint/40 p-3 sm:p-4">
165+
<div className="flex items-start gap-3">
166+
<span
167+
aria-hidden
168+
className="shrink-0 inline-flex items-center justify-center w-7 h-7 rounded-full bg-info text-fg-on-accent"
169+
>
170+
<Lightbulb className="w-4 h-4" />
171+
</span>
172+
<div className="min-w-0 flex-1">
173+
<h3 className="text-sm font-semibold">{event.title}</h3>
174+
<p className="mt-1 text-xs text-fg-muted">{event.summary}</p>
175+
</div>
176+
</div>
177+
</article>
178+
);
179+
}
180+
181+
// ---------------------------------------------------------------------------
182+
// AgentActivityGroupCard
183+
// ---------------------------------------------------------------------------
184+
185+
/**
186+
* Grouped card for a burst of `trial.agent_activity` events. Shows what the
187+
* discovery agent is doing — tool calls, thinking snippets, assistant text.
188+
* Only the latest 3 items are shown to keep the feed compact.
189+
*/
190+
export function AgentActivityGroupCard({ items }: { items: TrialAgentActivityEvent[] }) {
191+
// Show only the most recent items to avoid feed spam
192+
const visible = items.slice(-3);
193+
return (
194+
<article
195+
data-testid="trial-activity-group"
196+
className="rounded-md border border-border-default bg-surface/60 p-3 sm:p-4"
197+
>
198+
<div className="flex items-start gap-3">
199+
<span
200+
aria-hidden
201+
className="shrink-0 inline-flex items-center justify-center w-7 h-7 rounded-full bg-canvas border border-border-default text-fg-muted trial-skeleton-active"
202+
>
203+
<Brain className="w-4 h-4" />
204+
</span>
205+
<div className="min-w-0 flex-1">
206+
<h3 className="text-xs font-medium text-fg-muted">
207+
Agent working&hellip;
208+
</h3>
209+
<ul className="mt-2 flex flex-col gap-1.5">
210+
{visible.map((item, idx) => (
211+
<li key={`${idx}-${item.at}`} className="flex items-start gap-2 text-xs text-fg-muted">
212+
<ActivityRoleIcon role={item.role} />
213+
<span className="min-w-0 break-words line-clamp-2">
214+
{item.toolName ? (
215+
<><code className="font-mono text-[11px] text-accent">{item.toolName}</code>{' '}</>
216+
) : null}
217+
{cleanActivityText(item.text)}
218+
</span>
219+
</li>
220+
))}
221+
</ul>
222+
{items.length > 3 ? (
223+
<p className="mt-1 text-[11px] text-fg-muted">
224+
+{items.length - 3} more actions
225+
</p>
226+
) : null}
227+
</div>
228+
</div>
229+
</article>
230+
);
231+
}
232+
233+
function ActivityRoleIcon({ role }: { role: 'assistant' | 'tool' | 'thinking' }) {
234+
switch (role) {
235+
case 'tool':
236+
return <Wrench className="w-3 h-3 shrink-0 mt-0.5" />;
237+
case 'thinking':
238+
return <Brain className="w-3 h-3 shrink-0 mt-0.5" />;
239+
default:
240+
return <Terminal className="w-3 h-3 shrink-0 mt-0.5" />;
241+
}
242+
}
243+
244+
// ---------------------------------------------------------------------------
245+
// StageSkeleton — pre-event roadmap
246+
// ---------------------------------------------------------------------------
247+
248+
/**
249+
* Skeleton timeline rendered before the first SSE event arrives.
250+
* Highlights the current stage (when known) and dims completed/upcoming.
251+
*/
252+
export function StageSkeleton({ activeStage }: { activeStage?: string }) {
253+
const activeIdx = activeStage
254+
? STAGE_TIMELINE.findIndex((s) => s.key === activeStage)
255+
: -1;
256+
257+
return (
258+
<div
259+
data-testid="trial-stage-skeleton"
260+
className="rounded-md border border-border-default bg-surface p-4"
261+
>
262+
<p className="text-xs text-fg-muted uppercase tracking-wide mb-3">
263+
Setting things up
264+
</p>
265+
<ol className="flex flex-col gap-2">
266+
{STAGE_TIMELINE.map((stage, idx) => {
267+
const isActive = idx === activeIdx;
268+
const isComplete = activeIdx >= 0 && idx < activeIdx;
269+
return (
270+
<li key={stage.key} className="flex items-center gap-3 text-sm">
271+
<span
272+
aria-hidden
273+
className={[
274+
'inline-flex items-center justify-center w-5 h-5 rounded-full text-[11px] font-semibold shrink-0',
275+
isComplete
276+
? 'bg-success-tint text-success-fg'
277+
: isActive
278+
? 'bg-accent text-fg-on-accent trial-skeleton-active'
279+
: 'bg-canvas border border-border-default text-fg-muted',
280+
].join(' ')}
281+
>
282+
{isComplete ? '✓' : idx + 1}
283+
</span>
284+
<span
285+
className={[
286+
'truncate',
287+
isActive
288+
? 'text-fg-primary font-medium'
289+
: isComplete
290+
? 'text-fg-muted line-through decoration-1'
291+
: 'text-fg-muted',
292+
].join(' ')}
293+
>
294+
{stage.label}
295+
</span>
296+
</li>
297+
);
298+
})}
299+
</ol>
300+
</div>
301+
);
302+
}
303+
304+
// ---------------------------------------------------------------------------
305+
// TerminalErrorPanel
306+
// ---------------------------------------------------------------------------
307+
308+
export function TerminalErrorPanel({ error }: { error: TrialErrorEvent }) {
309+
const friendly = error.message || trialErrorMessage(error.error);
310+
const isRetryable = error.error !== 'cap_exceeded' && error.error !== 'trials_disabled';
311+
return (
312+
<Alert variant="error" data-testid="trial-error-panel">
313+
<div className="flex flex-col gap-2">
314+
<p>
315+
<strong>SAM hit a snag:</strong> {friendly}
316+
</p>
317+
<div className="flex flex-wrap gap-3">
318+
{isRetryable ? (
319+
<Link
320+
to="/try"
321+
className="inline-flex items-center min-h-[44px] text-sm font-medium underline underline-offset-2"
322+
data-testid="trial-error-retry"
323+
>
324+
Try again →
325+
</Link>
326+
) : (
327+
<Link
328+
to="/try/cap-exceeded"
329+
className="inline-flex items-center min-h-[44px] text-sm font-medium underline underline-offset-2"
330+
>
331+
Join the waitlist →
332+
</Link>
333+
)}
334+
</div>
335+
</div>
336+
</Alert>
337+
);
338+
}

0 commit comments

Comments
 (0)