|
| 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… |
| 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… |
| 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