@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,165 @@
1
+ import * as React from "react";
2
+ import clsx from "clsx";
3
+ import { Button, InlineNotification, Surface, Textarea } from "@echothink-ui/core";
4
+ import { ExternalLinkIcon } from "@echothink-ui/icons";
5
+ import type { SurfaceComponentProps } from "@echothink-ui/core";
6
+ import type { AgentApprovalAction, AgentCitation } from "../types";
7
+ import { riskSeverity } from "./utils";
8
+
9
+ export interface AgentApprovalGateProps extends Omit<SurfaceComponentProps, "children"> {
10
+ action: AgentApprovalAction;
11
+ evidence?: AgentCitation[];
12
+ onApprove?: (reason?: string) => void;
13
+ onReject?: (reason?: string) => void;
14
+ state?: "idle" | "pending" | "approved" | "rejected";
15
+ }
16
+
17
+ export function AgentApprovalGate({
18
+ action,
19
+ evidence = [],
20
+ onApprove,
21
+ onReject,
22
+ state = "idle",
23
+ title = "Approval required",
24
+ className,
25
+ ...props
26
+ }: AgentApprovalGateProps) {
27
+ const [reason, setReason] = React.useState("");
28
+ const severity = riskSeverity(action.riskLevel);
29
+ const locked = state === "pending" || state === "approved" || state === "rejected";
30
+ const stateLabel = approvalStateLabel(state);
31
+
32
+ return (
33
+ <Surface
34
+ {...props}
35
+ className={clsx("eth-agent-approval-gate", `eth-agent-approval-gate--${state}`, className)}
36
+ data-eth-component="AgentApprovalGate"
37
+ severity={severity}
38
+ status={approvalStatus(state)}
39
+ title={title}
40
+ subtitle={action.label}
41
+ >
42
+ <div className="eth-agent-approval-gate__layout">
43
+ <InlineNotification
44
+ severity={severity}
45
+ title={`${riskLabel(action.riskLevel)} risk action`}
46
+ >
47
+ {action.description}
48
+ </InlineNotification>
49
+
50
+ <dl className="eth-agent-approval-gate__summary" aria-label="Approval action details">
51
+ <div>
52
+ <dt>Action</dt>
53
+ <dd>{action.id}</dd>
54
+ </div>
55
+ <div>
56
+ <dt>Risk</dt>
57
+ <dd>{riskLabel(action.riskLevel)}</dd>
58
+ </div>
59
+ <div>
60
+ <dt>Status</dt>
61
+ <dd>{stateLabel}</dd>
62
+ </div>
63
+ </dl>
64
+
65
+ {evidence.length ? (
66
+ <section className="eth-agent-approval-gate__evidence">
67
+ <div className="eth-agent-approval-gate__section-header">
68
+ <h3>Evidence</h3>
69
+ <span>
70
+ {evidence.length} reference{evidence.length === 1 ? "" : "s"}
71
+ </span>
72
+ </div>
73
+ <div className="eth-agent-approval-gate__evidence-list">
74
+ {evidence.map((item) => (
75
+ <EvidenceItem key={item.id} item={item} />
76
+ ))}
77
+ </div>
78
+ </section>
79
+ ) : null}
80
+
81
+ <Textarea
82
+ className="eth-agent-approval-gate__reason"
83
+ aria-label="Approval reason"
84
+ disabled={locked}
85
+ labelText="Approval rationale"
86
+ placeholder="Optional reason"
87
+ rows={2}
88
+ value={reason}
89
+ onChange={(event) => setReason(event.currentTarget.value)}
90
+ />
91
+
92
+ <div className="eth-agent-approval-gate__actions">
93
+ <Button
94
+ density="default"
95
+ intent="success"
96
+ loading={state === "pending"}
97
+ disabled={locked}
98
+ onClick={() => onApprove?.(reason || undefined)}
99
+ >
100
+ {state === "approved" ? "Approved" : "Approve"}
101
+ </Button>
102
+ <Button
103
+ density="default"
104
+ intent="danger"
105
+ disabled={locked}
106
+ onClick={() => onReject?.(reason || undefined)}
107
+ >
108
+ {state === "rejected" ? "Rejected" : "Reject"}
109
+ </Button>
110
+ </div>
111
+ </div>
112
+ </Surface>
113
+ );
114
+ }
115
+
116
+ function EvidenceItem({ item }: { item: AgentCitation }) {
117
+ const content = (
118
+ <>
119
+ <span className="eth-agent-approval-gate__evidence-copy">
120
+ <span className="eth-agent-approval-gate__evidence-label">{item.label}</span>
121
+ {item.description ? (
122
+ <span className="eth-agent-approval-gate__evidence-description">{item.description}</span>
123
+ ) : null}
124
+ </span>
125
+ {item.meta ? (
126
+ <span className="eth-agent-approval-gate__evidence-meta">{item.meta}</span>
127
+ ) : null}
128
+ {item.href ? (
129
+ <ExternalLinkIcon className="eth-agent-approval-gate__evidence-icon" size={16} />
130
+ ) : null}
131
+ </>
132
+ );
133
+
134
+ if (item.href) {
135
+ return (
136
+ <a
137
+ className="eth-agent-approval-gate__evidence-item eth-agent-approval-gate__evidence-item--link"
138
+ href={item.href}
139
+ >
140
+ {content}
141
+ </a>
142
+ );
143
+ }
144
+
145
+ return <div className="eth-agent-approval-gate__evidence-item">{content}</div>;
146
+ }
147
+
148
+ function riskLabel(riskLevel: AgentApprovalAction["riskLevel"]) {
149
+ if (!riskLevel) return "Low";
150
+ return riskLevel.charAt(0).toUpperCase() + riskLevel.slice(1);
151
+ }
152
+
153
+ function approvalStateLabel(state: AgentApprovalGateProps["state"]) {
154
+ if (state === "pending") return "Submitting";
155
+ if (state === "approved") return "Approved";
156
+ if (state === "rejected") return "Rejected";
157
+ return "Awaiting decision";
158
+ }
159
+
160
+ function approvalStatus(state: AgentApprovalGateProps["state"]) {
161
+ if (state === "pending") return "running";
162
+ if (state === "approved") return "completed";
163
+ if (state === "rejected") return "blocked";
164
+ return "approval-required";
165
+ }
@@ -0,0 +1,161 @@
1
+ import * as React from "react";
2
+ import clsx from "clsx";
3
+ import { Badge, EmptyState, Surface } from "@echothink-ui/core";
4
+ import {
5
+ AgentRunningIcon,
6
+ ChevronRightIcon,
7
+ DocumentIcon,
8
+ ExternalLinkIcon,
9
+ MessageIcon,
10
+ SearchIcon,
11
+ type EthIconProps
12
+ } from "@echothink-ui/icons";
13
+ import type { SurfaceComponentProps } from "@echothink-ui/core";
14
+ import type { AgentContextSource } from "../types";
15
+
16
+ export interface AgentContextViewerProps extends Omit<
17
+ SurfaceComponentProps,
18
+ "children" | "onSelect"
19
+ > {
20
+ sources: AgentContextSource[];
21
+ onSelect?: (source: AgentContextSource) => void;
22
+ }
23
+
24
+ export function AgentContextViewer({
25
+ sources,
26
+ onSelect,
27
+ title = "Context sources",
28
+ className,
29
+ ...props
30
+ }: AgentContextViewerProps) {
31
+ const sourceListLabel = typeof title === "string" ? title : "Context sources";
32
+ const sortedSources = React.useMemo(
33
+ () => [...sources].sort((a, b) => (b.relevance ?? -1) - (a.relevance ?? -1)),
34
+ [sources]
35
+ );
36
+
37
+ return (
38
+ <Surface
39
+ {...props}
40
+ className={clsx("eth-agent-context-viewer", className)}
41
+ data-eth-component="AgentContextViewer"
42
+ title={title}
43
+ >
44
+ {!sortedSources.length ? (
45
+ <EmptyState title="No context sources" />
46
+ ) : (
47
+ <ul className="eth-agent-context-viewer__sources" aria-label={sourceListLabel}>
48
+ {sortedSources.map((source) => {
49
+ const SourceIcon = sourceIcon(source.type);
50
+ const relevance = source.relevance == null ? null : formatRelevance(source.relevance);
51
+ const relevanceValue =
52
+ source.relevance == null ? null : normalizedRelevance(source.relevance);
53
+ const isInteractive = Boolean(source.href || onSelect);
54
+
55
+ const content = (
56
+ <>
57
+ <span className="eth-agent-context-viewer__kind-icon" aria-hidden="true">
58
+ <SourceIcon size={18} />
59
+ </span>
60
+ <span className="eth-agent-context-viewer__main">
61
+ <span className="eth-agent-context-viewer__topline">
62
+ <strong className="eth-agent-context-viewer__label">{source.label}</strong>
63
+ <Badge severity="neutral">{typeLabel(source.type)}</Badge>
64
+ </span>
65
+ {source.excerpt ? (
66
+ <span className="eth-agent-context-viewer__excerpt">{source.excerpt}</span>
67
+ ) : null}
68
+ </span>
69
+ <span className="eth-agent-context-viewer__aside">
70
+ {relevance != null && relevanceValue != null ? (
71
+ <span
72
+ className="eth-agent-context-viewer__relevance"
73
+ aria-label={`Relevance ${relevance}`}
74
+ >
75
+ <span className="eth-agent-context-viewer__relevance-value">
76
+ Relevance {relevance}
77
+ </span>
78
+ <span
79
+ className="eth-agent-context-viewer__relevance-meter"
80
+ aria-hidden="true"
81
+ >
82
+ <span style={{ inlineSize: `${relevanceValue}%` }} />
83
+ </span>
84
+ </span>
85
+ ) : (
86
+ <span className="eth-agent-context-viewer__unranked">Unranked</span>
87
+ )}
88
+ {source.href ? (
89
+ <ExternalLinkIcon className="eth-agent-context-viewer__indicator" size={16} />
90
+ ) : isInteractive ? (
91
+ <ChevronRightIcon className="eth-agent-context-viewer__indicator" size={16} />
92
+ ) : null}
93
+ </span>
94
+ </>
95
+ );
96
+
97
+ return (
98
+ <li key={source.id}>
99
+ {source.href ? (
100
+ <a
101
+ className={clsx(
102
+ "eth-agent-context-viewer__source",
103
+ "eth-agent-context-viewer__source--interactive"
104
+ )}
105
+ href={source.href}
106
+ onClick={() => onSelect?.(source)}
107
+ >
108
+ {content}
109
+ </a>
110
+ ) : onSelect ? (
111
+ <button
112
+ type="button"
113
+ className={clsx(
114
+ "eth-agent-context-viewer__source",
115
+ "eth-agent-context-viewer__source--interactive"
116
+ )}
117
+ onClick={() => onSelect(source)}
118
+ >
119
+ {content}
120
+ </button>
121
+ ) : (
122
+ <article className="eth-agent-context-viewer__source">{content}</article>
123
+ )}
124
+ </li>
125
+ );
126
+ })}
127
+ </ul>
128
+ )}
129
+ </Surface>
130
+ );
131
+ }
132
+
133
+ function sourceIcon(type: AgentContextSource["type"]): React.ComponentType<EthIconProps> {
134
+ switch (type) {
135
+ case "document":
136
+ return DocumentIcon;
137
+ case "query":
138
+ return SearchIcon;
139
+ case "tool-result":
140
+ return AgentRunningIcon;
141
+ case "message":
142
+ default:
143
+ return MessageIcon;
144
+ }
145
+ }
146
+
147
+ function typeLabel(type: AgentContextSource["type"]) {
148
+ return type
149
+ .split("-")
150
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
151
+ .join(" ");
152
+ }
153
+
154
+ function formatRelevance(relevance: number) {
155
+ return `${normalizedRelevance(relevance)}%`;
156
+ }
157
+
158
+ function normalizedRelevance(relevance: number) {
159
+ const normalized = relevance <= 1 ? relevance * 100 : relevance;
160
+ return Math.min(100, Math.max(0, Math.round(normalized)));
161
+ }
@@ -0,0 +1,224 @@
1
+ import * as React from "react";
2
+ import clsx from "clsx";
3
+ import { Drawer, EmptyState, LinkButton, Surface } from "@echothink-ui/core";
4
+ import type { SurfaceComponentProps } from "@echothink-ui/core";
5
+ import type { AgentGeneratedArtifact } from "../types";
6
+ import { formatDateTime } from "./utils";
7
+
8
+ export interface AgentGeneratedArtifactPanelProps extends Omit<
9
+ SurfaceComponentProps,
10
+ "children" | "onSelect"
11
+ > {
12
+ artifacts: AgentGeneratedArtifact[];
13
+ onSelect?: (artifact: AgentGeneratedArtifact) => void;
14
+ selectedId?: string;
15
+ }
16
+
17
+ export function AgentGeneratedArtifactPanel({
18
+ artifacts,
19
+ onSelect,
20
+ selectedId,
21
+ density,
22
+ title = "Generated artifacts",
23
+ className,
24
+ ...props
25
+ }: AgentGeneratedArtifactPanelProps) {
26
+ const [internalSelectedId, setInternalSelectedId] = React.useState<string | undefined>(
27
+ selectedId ?? artifacts[0]?.id
28
+ );
29
+ const [previewOpen, setPreviewOpen] = React.useState(false);
30
+ const selectedArtifact =
31
+ artifacts.find((artifact) => artifact.id === (selectedId ?? internalSelectedId)) ??
32
+ artifacts[0];
33
+ const compact = density === "compact";
34
+
35
+ const selectArtifact = (artifact: AgentGeneratedArtifact) => {
36
+ setInternalSelectedId(artifact.id);
37
+ setPreviewOpen(true);
38
+ onSelect?.(artifact);
39
+ };
40
+
41
+ return (
42
+ <>
43
+ <Surface
44
+ {...props}
45
+ density={density}
46
+ className={clsx("eth-agent-generated-artifact-panel", className)}
47
+ data-eth-component="AgentGeneratedArtifactPanel"
48
+ title={title}
49
+ >
50
+ {!artifacts.length ? (
51
+ <EmptyState title="No artifacts generated" />
52
+ ) : (
53
+ <div
54
+ className="eth-agent-generated-artifact-panel__layout"
55
+ style={{
56
+ gridTemplateColumns: compact ? "1fr" : "minmax(220px, 320px) minmax(0, 1fr)"
57
+ }}
58
+ >
59
+ <ArtifactList
60
+ artifacts={artifacts}
61
+ selectedId={selectedArtifact?.id}
62
+ onSelect={selectArtifact}
63
+ />
64
+ {!compact ? <ArtifactPreview artifact={selectedArtifact} /> : null}
65
+ </div>
66
+ )}
67
+ </Surface>
68
+ {compact ? (
69
+ <Drawer
70
+ open={previewOpen && Boolean(selectedArtifact)}
71
+ title={selectedArtifact?.label ?? "Artifact preview"}
72
+ onClose={() => setPreviewOpen(false)}
73
+ >
74
+ <ArtifactPreview artifact={selectedArtifact} />
75
+ </Drawer>
76
+ ) : null}
77
+ </>
78
+ );
79
+ }
80
+
81
+ function ArtifactList({
82
+ artifacts,
83
+ selectedId,
84
+ onSelect
85
+ }: {
86
+ artifacts: AgentGeneratedArtifact[];
87
+ selectedId?: string;
88
+ onSelect: (artifact: AgentGeneratedArtifact) => void;
89
+ }) {
90
+ return (
91
+ <div
92
+ className="eth-agent-generated-artifact-panel__list"
93
+ role="group"
94
+ aria-label="Generated artifacts"
95
+ >
96
+ {artifacts.map((artifact) => {
97
+ const selected = selectedId === artifact.id;
98
+
99
+ return (
100
+ <button
101
+ key={artifact.id}
102
+ type="button"
103
+ aria-pressed={selected}
104
+ className={clsx(
105
+ "eth-agent-generated-artifact-panel__item",
106
+ selected && "eth-agent-generated-artifact-panel__item--selected"
107
+ )}
108
+ onClick={() => onSelect(artifact)}
109
+ >
110
+ <span className="eth-agent-generated-artifact-panel__item-topline">
111
+ <span className="eth-agent-generated-artifact-panel__item-title">
112
+ {artifact.label}
113
+ </span>
114
+ <span className="eth-agent-generated-artifact-panel__kind">
115
+ {artifactKindLabel(artifact.kind)}
116
+ </span>
117
+ </span>
118
+ <span className="eth-agent-generated-artifact-panel__item-meta">
119
+ {artifactMeta(artifact).join(" · ")}
120
+ </span>
121
+ <span className="eth-agent-generated-artifact-panel__item-status">
122
+ {artifactPreviewStatus(artifact)}
123
+ </span>
124
+ </button>
125
+ );
126
+ })}
127
+ </div>
128
+ );
129
+ }
130
+
131
+ function ArtifactPreview({ artifact }: { artifact?: AgentGeneratedArtifact }) {
132
+ const headingId = React.useId();
133
+ if (!artifact) return <EmptyState title="Select an artifact" />;
134
+
135
+ return (
136
+ <section className="eth-agent-generated-artifact-panel__preview" aria-labelledby={headingId}>
137
+ <header className="eth-agent-generated-artifact-panel__preview-header">
138
+ <div className="eth-agent-generated-artifact-panel__preview-heading">
139
+ <h3 id={headingId}>{artifact.label}</h3>
140
+ <div className="eth-agent-generated-artifact-panel__preview-meta">
141
+ {artifactMeta(artifact).map((item) => (
142
+ <span key={item}>{item}</span>
143
+ ))}
144
+ {artifact.mimeType ? <span>{artifact.mimeType}</span> : null}
145
+ </div>
146
+ </div>
147
+ {artifact.href ? (
148
+ <LinkButton href={artifact.href} density="compact" intent="secondary">
149
+ Open
150
+ </LinkButton>
151
+ ) : null}
152
+ </header>
153
+
154
+ <div className="eth-agent-generated-artifact-panel__preview-body">
155
+ <ArtifactPreviewBody artifact={artifact} />
156
+ </div>
157
+ </section>
158
+ );
159
+ }
160
+
161
+ function ArtifactPreviewBody({ artifact }: { artifact: AgentGeneratedArtifact }) {
162
+ const content = artifact.content?.trim();
163
+
164
+ if (artifact.kind === "image" && artifact.previewUrl) {
165
+ return <img alt={artifact.label} src={artifact.previewUrl} />;
166
+ }
167
+
168
+ if (artifact.kind === "code" && content) {
169
+ return (
170
+ <pre>
171
+ <code>{content}</code>
172
+ </pre>
173
+ );
174
+ }
175
+
176
+ if (artifact.previewUrl) {
177
+ return <iframe title={artifact.label} src={artifact.previewUrl} />;
178
+ }
179
+
180
+ if (content) {
181
+ return <div className="eth-agent-generated-artifact-panel__text-preview">{content}</div>;
182
+ }
183
+
184
+ return (
185
+ <div className="eth-agent-generated-artifact-panel__empty-preview" role="status">
186
+ <p className="eth-agent-generated-artifact-panel__empty-title">Preview unavailable</p>
187
+ <p className="eth-agent-generated-artifact-panel__empty-description">
188
+ {artifact.href
189
+ ? "Open the artifact to inspect the generated output."
190
+ : "Artifact saved without an inline preview."}
191
+ </p>
192
+ </div>
193
+ );
194
+ }
195
+
196
+ function artifactMeta(artifact: AgentGeneratedArtifact) {
197
+ return [artifact.size, formatDateTime(artifact.createdAt)].filter((item): item is string =>
198
+ Boolean(item)
199
+ );
200
+ }
201
+
202
+ function artifactKindLabel(kind: AgentGeneratedArtifact["kind"]) {
203
+ switch (kind) {
204
+ case "chart":
205
+ return "Chart";
206
+ case "code":
207
+ return "Code";
208
+ case "doc":
209
+ return "Doc";
210
+ case "file":
211
+ return "File";
212
+ case "image":
213
+ return "Image";
214
+ }
215
+ }
216
+
217
+ function artifactPreviewStatus(artifact: AgentGeneratedArtifact) {
218
+ if (artifact.kind === "image" && artifact.previewUrl) return "Inline image preview";
219
+ if (artifact.kind === "code" && artifact.content?.trim()) return "Inline code preview";
220
+ if (artifact.previewUrl) return "Embedded preview";
221
+ if (artifact.content?.trim()) return "Inline text preview";
222
+ if (artifact.href) return "Open externally";
223
+ return "No inline preview";
224
+ }
@@ -0,0 +1,154 @@
1
+ import * as React from "react";
2
+ import clsx from "clsx";
3
+ import {
4
+ Badge,
5
+ Button,
6
+ EmptyState,
7
+ FormField,
8
+ StatusDot,
9
+ Surface,
10
+ Textarea
11
+ } from "@echothink-ui/core";
12
+ import type { SurfaceComponentProps } from "@echothink-ui/core";
13
+ import type { AgentHandoffTarget } from "../types";
14
+
15
+ export interface AgentHandoffPanelProps
16
+ extends Omit<SurfaceComponentProps, "children" | "onChange" | "onSubmit"> {
17
+ itemRef?: string;
18
+ agentRef?: string;
19
+ assigneeOptions?: AgentHandoffTarget[];
20
+ selectedAssigneeId?: string;
21
+ reason?: string;
22
+ reasonSchema?: { label?: string; placeholder?: string; required?: boolean };
23
+ disabled?: boolean;
24
+ onAssigneeChange?: (id: string) => void;
25
+ onReasonChange?: (reason: string) => void;
26
+ onSubmit?: (handoff: { assigneeId?: string; reason: string }) => void;
27
+ }
28
+
29
+ export function AgentHandoffPanel({
30
+ itemRef,
31
+ agentRef,
32
+ assigneeOptions = [],
33
+ selectedAssigneeId,
34
+ reason,
35
+ reasonSchema,
36
+ disabled,
37
+ onAssigneeChange,
38
+ onReasonChange,
39
+ onSubmit,
40
+ title = "Agent handoff",
41
+ className,
42
+ ...props
43
+ }: AgentHandoffPanelProps) {
44
+ const [internalAssigneeId, setInternalAssigneeId] = React.useState(selectedAssigneeId);
45
+ const [internalReason, setInternalReason] = React.useState(reason ?? "");
46
+ const assigneeId = selectedAssigneeId ?? internalAssigneeId;
47
+ const handoffReason = reason ?? internalReason;
48
+ const reasonRequired = reasonSchema?.required ?? true;
49
+ const canSubmit = Boolean(assigneeId) && (!reasonRequired || Boolean(handoffReason.trim()));
50
+
51
+ const selectAssignee = (id: string) => {
52
+ setInternalAssigneeId(id);
53
+ onAssigneeChange?.(id);
54
+ };
55
+
56
+ const changeReason = (value: string) => {
57
+ setInternalReason(value);
58
+ onReasonChange?.(value);
59
+ };
60
+ const reasonId = React.useId();
61
+
62
+ return (
63
+ <Surface
64
+ {...props}
65
+ className={clsx("eth-agent-handoff-panel", className)}
66
+ data-eth-component="AgentHandoffPanel"
67
+ title={title}
68
+ subtitle={[agentRef, itemRef].filter(Boolean).join(" · ") || undefined}
69
+ >
70
+ <div className="eth-agent-handoff-panel__body">
71
+ {!assigneeOptions.length ? (
72
+ <EmptyState title="No handoff targets" />
73
+ ) : (
74
+ <div
75
+ className="eth-agent-handoff-panel__target-list"
76
+ role="listbox"
77
+ aria-label="Handoff targets"
78
+ >
79
+ {assigneeOptions.map((target) => (
80
+ <button
81
+ key={target.id}
82
+ type="button"
83
+ role="option"
84
+ aria-selected={assigneeId === target.id}
85
+ className={clsx(
86
+ "eth-agent-handoff-panel__target",
87
+ assigneeId === target.id && "eth-agent-handoff-panel__target--selected"
88
+ )}
89
+ disabled={disabled}
90
+ onClick={() => selectAssignee(target.id)}
91
+ >
92
+ <span className="eth-agent-handoff-panel__target-main">
93
+ <span className="eth-agent-handoff-panel__target-copy">
94
+ <strong className="eth-agent-handoff-panel__target-label">
95
+ {target.label}
96
+ </strong>
97
+ {target.description ? (
98
+ <span className="eth-agent-handoff-panel__target-description">
99
+ {target.description}
100
+ </span>
101
+ ) : null}
102
+ </span>
103
+ <span className="eth-agent-handoff-panel__target-meta">
104
+ <Badge severity="neutral">{target.type}</Badge>
105
+ {target.status ? (
106
+ <StatusDot
107
+ status={target.status}
108
+ label={formatHandoffStatus(target.status)}
109
+ />
110
+ ) : null}
111
+ </span>
112
+ </span>
113
+ </button>
114
+ ))}
115
+ </div>
116
+ )}
117
+
118
+ <FormField
119
+ id={reasonId}
120
+ label={reasonSchema?.label ?? "Handoff reason"}
121
+ required={reasonRequired}
122
+ >
123
+ <Textarea
124
+ className="eth-agent-handoff-panel__reason"
125
+ disabled={disabled}
126
+ placeholder={reasonSchema?.placeholder ?? "Explain what the next owner should do."}
127
+ required={reasonRequired}
128
+ rows={3}
129
+ value={handoffReason}
130
+ onChange={(event) => changeReason(event.currentTarget.value)}
131
+ />
132
+ </FormField>
133
+
134
+ <div className="eth-agent-handoff-panel__actions">
135
+ <Button
136
+ disabled={disabled || !canSubmit}
137
+ onClick={() => onSubmit?.({ assigneeId, reason: handoffReason })}
138
+ >
139
+ Handoff
140
+ </Button>
141
+ </div>
142
+ </div>
143
+ </Surface>
144
+ );
145
+ }
146
+
147
+ function formatHandoffStatus(status: AgentHandoffTarget["status"]) {
148
+ return status
149
+ ? status
150
+ .split("-")
151
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
152
+ .join(" ")
153
+ : undefined;
154
+ }