@echothink-ui/agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +5 -0
  2. package/dist/components/AgentApprovalGate.d.ts +10 -0
  3. package/dist/components/AgentContextViewer.d.ts +7 -0
  4. package/dist/components/AgentGeneratedArtifactPanel.d.ts +8 -0
  5. package/dist/components/AgentHandoffPanel.d.ts +22 -0
  6. package/dist/components/AgentInterruptionPanel.d.ts +13 -0
  7. package/dist/components/AgentMemoryPanel.d.ts +9 -0
  8. package/dist/components/AgentMessageList.d.ts +8 -0
  9. package/dist/components/AgentPlanDiff.d.ts +7 -0
  10. package/dist/components/AgentPlanPreview.d.ts +6 -0
  11. package/dist/components/AgentPromptBox.d.ts +15 -0
  12. package/dist/components/AgentRunControls.d.ts +10 -0
  13. package/dist/components/AgentSafetyPanel.d.ts +6 -0
  14. package/dist/components/AgentStateBadge.d.ts +6 -0
  15. package/dist/components/AgentThinkingChain.d.ts +8 -0
  16. package/dist/components/AgentThinkingPanel.d.ts +8 -0
  17. package/dist/components/AgentToolCallLog.d.ts +7 -0
  18. package/dist/components/AgentTraceViewer.d.ts +6 -0
  19. package/dist/components/AppDomainAgentPanel.d.ts +17 -0
  20. package/dist/components/ChatAgentRail.d.ts +24 -0
  21. package/dist/components/ScopeAttachmentPanel.d.ts +7 -0
  22. package/dist/components/utils.d.ts +20 -0
  23. package/dist/index.cjs +2709 -0
  24. package/dist/index.cjs.map +1 -0
  25. package/dist/index.css +2433 -0
  26. package/dist/index.css.map +1 -0
  27. package/dist/index.d.ts +44 -0
  28. package/dist/index.js +2666 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/types.d.ts +128 -0
  31. package/package.json +45 -0
  32. package/src/components/AgentApprovalGate.tsx +165 -0
  33. package/src/components/AgentContextViewer.tsx +161 -0
  34. package/src/components/AgentGeneratedArtifactPanel.tsx +224 -0
  35. package/src/components/AgentHandoffPanel.tsx +154 -0
  36. package/src/components/AgentInterruptionPanel.tsx +85 -0
  37. package/src/components/AgentMemoryPanel.tsx +167 -0
  38. package/src/components/AgentMessageList.tsx +209 -0
  39. package/src/components/AgentPlanDiff.tsx +149 -0
  40. package/src/components/AgentPlanPreview.tsx +106 -0
  41. package/src/components/AgentPromptBox.tsx +163 -0
  42. package/src/components/AgentRunControls.tsx +221 -0
  43. package/src/components/AgentSafetyPanel.tsx +113 -0
  44. package/src/components/AgentStateBadge.tsx +30 -0
  45. package/src/components/AgentThinkingChain.tsx +151 -0
  46. package/src/components/AgentThinkingPanel.tsx +56 -0
  47. package/src/components/AgentToolCallLog.tsx +262 -0
  48. package/src/components/AgentTraceViewer.tsx +218 -0
  49. package/src/components/AppDomainAgentPanel.tsx +66 -0
  50. package/src/components/ChatAgentRail.tsx +192 -0
  51. package/src/components/ScopeAttachmentPanel.tsx +130 -0
  52. package/src/components/utils.ts +186 -0
  53. package/src/index.test.tsx +212 -0
  54. package/src/index.tsx +88 -0
  55. package/src/styles.css +2902 -0
  56. package/src/types.ts +158 -0
@@ -0,0 +1,85 @@
1
+ import * as React from "react";
2
+ import clsx from "clsx";
3
+ import { Button, InlineNotification, Surface, Textarea } from "@echothink-ui/core";
4
+ import type { SurfaceComponentProps } from "@echothink-ui/core";
5
+ import { InterruptIcon } from "@echothink-ui/icons";
6
+
7
+ export interface AgentInterruptionPanelProps
8
+ extends Omit<SurfaceComponentProps, "children" | "onChange" | "onSubmit"> {
9
+ prompt: string;
10
+ onChange: (prompt: string) => void;
11
+ onSubmit?: (prompt: string) => void;
12
+ disabled?: boolean;
13
+ promptLabel?: React.ReactNode;
14
+ helperText?: React.ReactNode;
15
+ placeholder?: string;
16
+ submitLabel?: React.ReactNode;
17
+ }
18
+
19
+ const defaultMetadata: NonNullable<SurfaceComponentProps["metadata"]> = [
20
+ { label: "Mode", value: "Steering interrupt" },
21
+ { label: "Delivery", value: "Before next agent step" }
22
+ ];
23
+
24
+ export function AgentInterruptionPanel({
25
+ prompt,
26
+ onChange,
27
+ onSubmit,
28
+ disabled,
29
+ title = "Interrupt agent",
30
+ subtitle = "Send operator guidance before the agent continues.",
31
+ metadata = defaultMetadata,
32
+ promptLabel = "Steering instruction",
33
+ helperText = "Delivered before the next agent step.",
34
+ placeholder = "Tell the agent what to change, stop, or prioritize.",
35
+ submitLabel = "Send interruption",
36
+ className,
37
+ ...props
38
+ }: AgentInterruptionPanelProps) {
39
+ const hasPrompt = Boolean(prompt.trim());
40
+ const submit = () => {
41
+ if (!disabled && hasPrompt) onSubmit?.(prompt);
42
+ };
43
+
44
+ return (
45
+ <Surface
46
+ {...props}
47
+ className={clsx("eth-agent-interruption-panel", className)}
48
+ data-eth-component="AgentInterruptionPanel"
49
+ metadata={metadata}
50
+ subtitle={subtitle}
51
+ title={title}
52
+ >
53
+ <div className="eth-agent-interruption-panel__body">
54
+ <InlineNotification severity="warning" title="Steering interrupt">
55
+ The agent will receive this instruction before continuing the current run.
56
+ </InlineNotification>
57
+ <Textarea
58
+ className="eth-agent-interruption-panel__prompt"
59
+ disabled={disabled}
60
+ helperText={helperText}
61
+ labelText={promptLabel}
62
+ placeholder={placeholder}
63
+ rows={4}
64
+ value={prompt}
65
+ onChange={(event) => onChange(event.currentTarget.value)}
66
+ onKeyDown={(event) => {
67
+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
68
+ event.preventDefault();
69
+ submit();
70
+ }
71
+ }}
72
+ />
73
+ <div className="eth-agent-interruption-panel__actions">
74
+ <Button
75
+ disabled={disabled || !hasPrompt}
76
+ icon={<InterruptIcon size={16} />}
77
+ onClick={submit}
78
+ >
79
+ {submitLabel}
80
+ </Button>
81
+ </div>
82
+ </div>
83
+ </Surface>
84
+ );
85
+ }
@@ -0,0 +1,167 @@
1
+ import * as React from "react";
2
+ import clsx from "clsx";
3
+ import { Badge, Button, EmptyState, Surface } from "@echothink-ui/core";
4
+ import type { SurfaceComponentProps } from "@echothink-ui/core";
5
+ import type { AgentMemoryEntry } from "../types";
6
+ import { formatDateTime } from "./utils";
7
+
8
+ export interface AgentMemoryPanelProps extends Omit<SurfaceComponentProps, "children"> {
9
+ entries: AgentMemoryEntry[];
10
+ onPin?: (id: string) => void;
11
+ onEdit?: (id: string) => void;
12
+ onDelete?: (id: string) => void;
13
+ }
14
+
15
+ const kinds: AgentMemoryEntry["kind"][] = ["user", "project", "feedback", "reference"];
16
+
17
+ export function AgentMemoryPanel({
18
+ entries,
19
+ onPin,
20
+ onEdit,
21
+ onDelete,
22
+ title = "Agent memory",
23
+ subtitle = "Stored instructions and retrieved context available to the agent",
24
+ className,
25
+ ...props
26
+ }: AgentMemoryPanelProps) {
27
+ const headingPrefix = React.useId();
28
+ const groups = React.useMemo(
29
+ () =>
30
+ kinds
31
+ .map((kind) => ({ kind, entries: entries.filter((entry) => entry.kind === kind) }))
32
+ .filter((group) => group.entries.length),
33
+ [entries]
34
+ );
35
+ const pinnedCount = React.useMemo(
36
+ () => entries.filter((entry) => entry.pinned).length,
37
+ [entries]
38
+ );
39
+ const latestUpdatedAt = React.useMemo(() => {
40
+ const sortedEntries = entries
41
+ .filter((entry): entry is AgentMemoryEntry & { lastUpdatedAt: string } =>
42
+ Boolean(entry.lastUpdatedAt && !Number.isNaN(new Date(entry.lastUpdatedAt).getTime()))
43
+ )
44
+ .sort((a, b) => new Date(b.lastUpdatedAt).getTime() - new Date(a.lastUpdatedAt).getTime());
45
+
46
+ return sortedEntries[0]?.lastUpdatedAt;
47
+ }, [entries]);
48
+
49
+ return (
50
+ <Surface
51
+ {...props}
52
+ className={clsx("eth-agent-memory-panel", className)}
53
+ data-eth-component="AgentMemoryPanel"
54
+ subtitle={subtitle}
55
+ title={title}
56
+ >
57
+ {!groups.length ? (
58
+ <EmptyState title="No memory entries" />
59
+ ) : (
60
+ <div className="eth-agent-memory-panel__body">
61
+ <dl className="eth-agent-memory-panel__summary" aria-label="Memory summary">
62
+ <div className="eth-agent-memory-panel__summary-item">
63
+ <dt>Total entries</dt>
64
+ <dd>{entries.length}</dd>
65
+ </div>
66
+ <div className="eth-agent-memory-panel__summary-item">
67
+ <dt>Pinned</dt>
68
+ <dd>{pinnedCount}</dd>
69
+ </div>
70
+ <div className="eth-agent-memory-panel__summary-item">
71
+ <dt>Latest update</dt>
72
+ <dd>
73
+ {latestUpdatedAt ? (
74
+ <time dateTime={latestUpdatedAt}>{formatDateTime(latestUpdatedAt)}</time>
75
+ ) : (
76
+ "Not recorded"
77
+ )}
78
+ </dd>
79
+ </div>
80
+ </dl>
81
+ {groups.map((group) => (
82
+ <section
83
+ key={group.kind}
84
+ className="eth-agent-memory-panel__group"
85
+ aria-labelledby={`${headingPrefix}-${group.kind}`}
86
+ >
87
+ <header className="eth-agent-memory-panel__group-header">
88
+ <h3 id={`${headingPrefix}-${group.kind}`}>{kindTitle(group.kind)}</h3>
89
+ <Badge severity="neutral">
90
+ {group.entries.length} {group.entries.length === 1 ? "entry" : "entries"}
91
+ </Badge>
92
+ </header>
93
+ <div className="eth-agent-memory-panel__entries">
94
+ {group.entries.map((entry) => (
95
+ <article
96
+ key={entry.id}
97
+ className={clsx(
98
+ "eth-agent-memory-panel__entry",
99
+ entry.pinned && "eth-agent-memory-panel__entry--pinned"
100
+ )}
101
+ >
102
+ <header className="eth-agent-memory-panel__entry-header">
103
+ <span className="eth-agent-memory-panel__entry-title">
104
+ <strong>{entry.key}</strong>
105
+ {entry.pinned ? <Badge severity="info">Pinned</Badge> : null}
106
+ </span>
107
+ {entry.lastUpdatedAt ? (
108
+ <time
109
+ className="eth-agent-memory-panel__updated-at"
110
+ dateTime={entry.lastUpdatedAt}
111
+ >
112
+ {formatDateTime(entry.lastUpdatedAt)}
113
+ </time>
114
+ ) : null}
115
+ </header>
116
+ <div className="eth-agent-memory-panel__value">{entry.value}</div>
117
+ {onPin || onEdit || onDelete ? (
118
+ <div
119
+ className="eth-agent-memory-panel__actions"
120
+ aria-label={`${entry.key} memory actions`}
121
+ >
122
+ {onPin ? (
123
+ <Button
124
+ aria-label={`${entry.pinned ? "Unpin" : "Pin"} ${entry.key}`}
125
+ density="compact"
126
+ intent="secondary"
127
+ onClick={() => onPin(entry.id)}
128
+ >
129
+ {entry.pinned ? "Unpin" : "Pin"}
130
+ </Button>
131
+ ) : null}
132
+ {onEdit ? (
133
+ <Button
134
+ aria-label={`Edit ${entry.key}`}
135
+ density="compact"
136
+ intent="secondary"
137
+ onClick={() => onEdit(entry.id)}
138
+ >
139
+ Edit
140
+ </Button>
141
+ ) : null}
142
+ {onDelete ? (
143
+ <Button
144
+ aria-label={`Delete ${entry.key}`}
145
+ density="compact"
146
+ intent="danger"
147
+ onClick={() => onDelete(entry.id)}
148
+ >
149
+ Delete
150
+ </Button>
151
+ ) : null}
152
+ </div>
153
+ ) : null}
154
+ </article>
155
+ ))}
156
+ </div>
157
+ </section>
158
+ ))}
159
+ </div>
160
+ )}
161
+ </Surface>
162
+ );
163
+ }
164
+
165
+ function kindTitle(kind: AgentMemoryEntry["kind"]) {
166
+ return kind.charAt(0).toUpperCase() + kind.slice(1);
167
+ }
@@ -0,0 +1,209 @@
1
+ import * as React from "react";
2
+ import clsx from "clsx";
3
+ import { StatusDot, Tag, statusLabel } from "@echothink-ui/core";
4
+ import type { AgentMessage } from "../types";
5
+ import { formatDateTime, mergeStyle } from "./utils";
6
+
7
+ export interface AgentMessageListProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
8
+ messages: AgentMessage[];
9
+ streaming?: boolean;
10
+ autoScroll?: boolean;
11
+ }
12
+
13
+ const roleLabels: Record<AgentMessage["role"], string> = {
14
+ user: "You",
15
+ agent: "Agent",
16
+ system: "System",
17
+ tool: "Tool"
18
+ };
19
+
20
+ export function AgentMessageList({
21
+ messages,
22
+ streaming,
23
+ autoScroll = true,
24
+ className,
25
+ style,
26
+ ...props
27
+ }: AgentMessageListProps) {
28
+ const listRef = React.useRef<HTMLDivElement>(null);
29
+
30
+ React.useEffect(() => {
31
+ if (!autoScroll) return;
32
+ const node = listRef.current;
33
+ if (!node) return;
34
+ node.scrollTo({ top: node.scrollHeight, behavior: "smooth" });
35
+ }, [autoScroll, messages.length, streaming]);
36
+
37
+ return (
38
+ <div
39
+ {...props}
40
+ ref={listRef}
41
+ role="log"
42
+ aria-live={streaming ? "polite" : "off"}
43
+ aria-relevant="additions text"
44
+ className={clsx("eth-message-list", "eth-agent-message-list", className)}
45
+ data-eth-component="AgentMessageList"
46
+ style={mergeStyle(
47
+ {
48
+ background: "var(--eth-color-layer-01)",
49
+ display: "flex",
50
+ flexDirection: "column",
51
+ gap: "1rem",
52
+ maxHeight: "100%",
53
+ overflowY: "auto",
54
+ padding: "1rem"
55
+ },
56
+ style
57
+ )}
58
+ >
59
+ {messages.map((message, index) => {
60
+ const isStreaming = Boolean(message.streaming || (streaming && index === messages.length - 1));
61
+ const isUser = message.role === "user";
62
+ const isSystem = message.role === "system";
63
+ const isTool = message.role === "tool";
64
+ const accentBorder = isTool
65
+ ? "3px solid var(--eth-color-border-strong)"
66
+ : "3px solid var(--eth-color-interactive-primary)";
67
+
68
+ return (
69
+ <article
70
+ key={message.id}
71
+ className={clsx(
72
+ "eth-message-list__item",
73
+ "eth-agent-message",
74
+ `eth-agent-message--${message.role}`,
75
+ isStreaming && "eth-agent-message--streaming"
76
+ )}
77
+ aria-busy={isStreaming || undefined}
78
+ style={{
79
+ alignSelf: isSystem ? "stretch" : isUser ? "flex-end" : "flex-start",
80
+ contentVisibility: "auto",
81
+ containIntrinsicSize: "1px 96px",
82
+ display: "grid",
83
+ gap: "0.375rem",
84
+ maxWidth: isSystem ? "100%" : "min(100%, 42rem)",
85
+ minWidth: 0,
86
+ textAlign: isSystem ? "center" : "start",
87
+ width: isSystem ? "100%" : "fit-content"
88
+ }}
89
+ >
90
+ <header
91
+ className="eth-agent-message__header"
92
+ style={{
93
+ alignItems: "center",
94
+ color: "var(--eth-color-text-secondary)",
95
+ display: "flex",
96
+ flexWrap: "wrap",
97
+ fontSize: "0.75rem",
98
+ gap: "0.5rem",
99
+ justifyContent: isUser ? "flex-end" : isSystem ? "center" : "flex-start",
100
+ lineHeight: 1.333
101
+ }}
102
+ >
103
+ <span
104
+ className="eth-agent-message__avatar"
105
+ aria-hidden
106
+ style={{
107
+ alignItems: "center",
108
+ background: isSystem
109
+ ? "transparent"
110
+ : isUser
111
+ ? "var(--eth-color-interactive-primary)"
112
+ : "var(--eth-color-layer-02)",
113
+ border: isSystem
114
+ ? "1px dashed var(--eth-color-border-subtle)"
115
+ : "1px solid var(--eth-color-border-subtle)",
116
+ borderRadius: "var(--eth-radius-none)",
117
+ color: isUser ? "#fff" : "var(--eth-color-text-primary)",
118
+ display: "inline-flex",
119
+ fontSize: "0.72rem",
120
+ fontWeight: 700,
121
+ height: 24,
122
+ justifyContent: "center",
123
+ width: 24
124
+ }}
125
+ >
126
+ {roleLabels[message.role].slice(0, 1)}
127
+ </span>
128
+ <strong>{roleLabels[message.role]}</strong>
129
+ <time dateTime={message.createdAt}>{formatDateTime(message.createdAt)}</time>
130
+ {message.status ? (
131
+ <StatusDot status={message.status} label={statusLabel(message.status)} />
132
+ ) : null}
133
+ </header>
134
+
135
+ <div
136
+ className="eth-agent-message__bubble"
137
+ style={{
138
+ background: isSystem
139
+ ? "var(--eth-color-layer-02)"
140
+ : isUser
141
+ ? "var(--eth-color-navy)"
142
+ : "var(--eth-color-layer-02)",
143
+ border: isSystem
144
+ ? "1px dashed var(--eth-color-border-subtle)"
145
+ : "1px solid var(--eth-color-border-subtle)",
146
+ borderInlineEnd: isStreaming ? accentBorder : undefined,
147
+ borderInlineStart: isUser || isSystem ? undefined : accentBorder,
148
+ borderRadius: "var(--eth-radius-panel)",
149
+ color: isUser ? "#fff" : "var(--eth-color-text-primary)",
150
+ fontFamily: isTool ? "ui-monospace, SFMono-Regular, Menlo, monospace" : undefined,
151
+ fontSize: isTool ? "0.875rem" : undefined,
152
+ lineHeight: 1.5,
153
+ overflowWrap: "anywhere",
154
+ padding: isSystem ? "0.5rem 0.75rem" : "0.875rem 1rem",
155
+ whiteSpace: isTool ? "pre-wrap" : "normal",
156
+ wordBreak: "normal"
157
+ }}
158
+ >
159
+ {message.content}
160
+ {isStreaming ? (
161
+ <span
162
+ className="eth-agent-message__cursor"
163
+ aria-hidden
164
+ style={{
165
+ animation: "eth-pulse 0.8s infinite",
166
+ color: "var(--eth-color-interactive-primary)",
167
+ display: "inline-block",
168
+ fontWeight: 600,
169
+ marginInlineStart: 3,
170
+ transform: "translateY(1px)"
171
+ }}
172
+ >
173
+
174
+ </span>
175
+ ) : null}
176
+ </div>
177
+
178
+ {message.citations?.length ? (
179
+ <div
180
+ className="eth-agent-message__citations"
181
+ aria-label="Citations"
182
+ style={{
183
+ display: "flex",
184
+ flexWrap: "wrap",
185
+ gap: "0.375rem",
186
+ justifyContent: isUser ? "flex-end" : isSystem ? "center" : "flex-start"
187
+ }}
188
+ >
189
+ {message.citations.map((citation) =>
190
+ citation.href ? (
191
+ <a
192
+ key={citation.id}
193
+ className="eth-agent-message__citation-link eth-tag"
194
+ href={citation.href}
195
+ >
196
+ {citation.label}
197
+ </a>
198
+ ) : (
199
+ <Tag key={citation.id}>{citation.label}</Tag>
200
+ )
201
+ )}
202
+ </div>
203
+ ) : null}
204
+ </article>
205
+ );
206
+ })}
207
+ </div>
208
+ );
209
+ }
@@ -0,0 +1,149 @@
1
+ import * as React from "react";
2
+ import clsx from "clsx";
3
+ import { Badge, Surface } from "@echothink-ui/core";
4
+ import type { SurfaceComponentProps } from "@echothink-ui/core";
5
+ import type { AgentPlanStep } from "../types";
6
+ import { diffPlanSteps } from "./utils";
7
+
8
+ export interface AgentPlanDiffProps extends Omit<SurfaceComponentProps, "children"> {
9
+ before: AgentPlanStep[];
10
+ after: AgentPlanStep[];
11
+ }
12
+
13
+ export function AgentPlanDiff({
14
+ before,
15
+ after,
16
+ title = "Plan diff",
17
+ subtitle = "Compare proposed plan changes before execution",
18
+ className,
19
+ ...props
20
+ }: AgentPlanDiffProps) {
21
+ const diff = React.useMemo(() => diffPlanSteps(before, after), [before, after]);
22
+ const summary = React.useMemo(
23
+ () => ({
24
+ added: diff.filter((item) => item.status === "added").length,
25
+ modified: diff.filter((item) => item.status === "modified").length,
26
+ removed: diff.filter((item) => item.status === "removed").length,
27
+ unchanged: diff.filter((item) => item.status === "unchanged").length
28
+ }),
29
+ [diff]
30
+ );
31
+
32
+ return (
33
+ <Surface
34
+ {...props}
35
+ className={clsx("eth-agent-plan-diff", className)}
36
+ data-eth-component="AgentPlanDiff"
37
+ subtitle={subtitle}
38
+ title={title}
39
+ >
40
+ <div className="eth-agent-plan-diff__body">
41
+ <dl className="eth-agent-plan-diff__summary" aria-label="Plan diff summary">
42
+ <SummaryItem label="Added" value={summary.added} tone="added" />
43
+ <SummaryItem label="Modified" value={summary.modified} tone="modified" />
44
+ <SummaryItem label="Removed" value={summary.removed} tone="removed" />
45
+ <SummaryItem label="Unchanged" value={summary.unchanged} tone="unchanged" />
46
+ </dl>
47
+
48
+ {diff.length ? (
49
+ <ol className="eth-agent-plan-diff__list" aria-label="Plan step changes">
50
+ {diff.map((item) => {
51
+ const stepId = item.after?.id ?? item.before?.id ?? item.id;
52
+ return (
53
+ <li key={item.id} className="eth-agent-plan-diff__entry">
54
+ <article
55
+ className={clsx(
56
+ "eth-agent-plan-diff__item",
57
+ `eth-agent-plan-diff__item--${item.status}`
58
+ )}
59
+ >
60
+ <header className="eth-agent-plan-diff__item-header">
61
+ <div className="eth-agent-plan-diff__item-heading">
62
+ <span className="eth-agent-plan-diff__step-id">Step {stepId}</span>
63
+ <Badge severity={item.severity}>{statusLabelFor(item.status)}</Badge>
64
+ </div>
65
+ </header>
66
+ {item.status === "modified" ? (
67
+ <div className="eth-agent-plan-diff__comparison">
68
+ <StepSnapshot label="Before" step={item.before} />
69
+ <StepSnapshot label="After" step={item.after} />
70
+ </div>
71
+ ) : (
72
+ <StepSnapshot
73
+ label={snapshotLabelFor(item.status)}
74
+ step={item.after ?? item.before}
75
+ />
76
+ )}
77
+ </article>
78
+ </li>
79
+ );
80
+ })}
81
+ </ol>
82
+ ) : (
83
+ <p className="eth-agent-plan-diff__empty">No plan steps to compare.</p>
84
+ )}
85
+ </div>
86
+ </Surface>
87
+ );
88
+ }
89
+
90
+ function StepSnapshot({ label, step }: { label: string; step?: AgentPlanStep }) {
91
+ if (!step) return null;
92
+ return (
93
+ <section className="eth-agent-plan-diff__snapshot">
94
+ <h3 className="eth-agent-plan-diff__snapshot-label">{label}</h3>
95
+ <p className="eth-agent-plan-diff__snapshot-description">{step.description}</p>
96
+ {step.durationEstimate || step.risks?.length ? (
97
+ <div className="eth-agent-plan-diff__snapshot-meta" aria-label={`${label} metadata`}>
98
+ {step.durationEstimate ? <Badge>{step.durationEstimate}</Badge> : null}
99
+ {step.risks?.map((risk) => (
100
+ <Badge key={risk} severity="warning">
101
+ {risk}
102
+ </Badge>
103
+ ))}
104
+ </div>
105
+ ) : null}
106
+ </section>
107
+ );
108
+ }
109
+
110
+ function SummaryItem({
111
+ label,
112
+ value,
113
+ tone
114
+ }: {
115
+ label: string;
116
+ value: number;
117
+ tone: "added" | "removed" | "modified" | "unchanged";
118
+ }) {
119
+ return (
120
+ <div
121
+ className={clsx(
122
+ "eth-agent-plan-diff__summary-item",
123
+ `eth-agent-plan-diff__summary-item--${tone}`
124
+ )}
125
+ >
126
+ <dt>{label}</dt>
127
+ <dd>{value}</dd>
128
+ </div>
129
+ );
130
+ }
131
+
132
+ function statusLabelFor(status: "added" | "removed" | "modified" | "unchanged") {
133
+ switch (status) {
134
+ case "added":
135
+ return "Added";
136
+ case "removed":
137
+ return "Removed";
138
+ case "modified":
139
+ return "Modified";
140
+ case "unchanged":
141
+ return "Unchanged";
142
+ }
143
+ }
144
+
145
+ function snapshotLabelFor(status: "added" | "removed" | "modified" | "unchanged") {
146
+ if (status === "added") return "Added step";
147
+ if (status === "removed") return "Removed step";
148
+ return "Step";
149
+ }