Share chat UI components in @sandbox-agent/react (#228)

* Extract shared chat UI components

* chore(release): update version to 0.3.1

* Use shared chat UI in Foundry
This commit is contained in:
Nathan Flurry 2026-03-10 22:31:36 -07:00 committed by GitHub
parent 6d7e67fe72
commit 0471214d65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1679 additions and 727 deletions

View file

@ -1348,6 +1348,13 @@
padding: 16px;
}
.chat-conversation {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
}
.messages-container:has(.empty-state) {
display: flex;
align-items: center;

View file

@ -1,3 +1,4 @@
import type { TranscriptEntry } from "@sandbox-agent/react";
import { BookOpen } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
@ -25,7 +26,6 @@ type AgentModeInfo = { id: string; name: string; description: string };
type AgentModelInfo = { id: string; name?: string };
import { IndexedDbSessionPersistDriver } from "@sandbox-agent/persist-indexeddb";
import ChatPanel from "./components/chat/ChatPanel";
import type { TimelineEntry } from "./components/chat/types";
import ConnectScreen from "./components/ConnectScreen";
import DebugPanel, { type DebugTab } from "./components/debug/DebugPanel";
import SessionSidebar from "./components/SessionSidebar";
@ -977,7 +977,7 @@ export default function App() {
// Build transcript entries from raw SessionEvents
const transcriptEntries = useMemo(() => {
const entries: TimelineEntry[] = [];
const entries: TranscriptEntry[] = [];
// Accumulators for streaming chunks
let assistantAccumId: string | null = null;
@ -1010,7 +1010,7 @@ export default function App() {
};
// Track tool calls by ID for updates
const toolEntryMap = new Map<string, TimelineEntry>();
const toolEntryMap = new Map<string, TranscriptEntry>();
for (const event of events) {
const payload = event.payload as Record<string, unknown>;
@ -1124,7 +1124,7 @@ export default function App() {
if (update.title) existing.toolName = update.title as string;
existing.time = time;
} else {
const entry: TimelineEntry = {
const entry: TranscriptEntry = {
id: `tool-${toolCallId}`,
eventId: event.id,
kind: "tool",

View file

@ -1,37 +0,0 @@
import { Send } from "lucide-react";
const ChatInput = ({
message,
onMessageChange,
onSendMessage,
onKeyDown,
placeholder,
disabled
}: {
message: string;
onMessageChange: (value: string) => void;
onSendMessage: () => void;
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
placeholder: string;
disabled: boolean;
}) => {
return (
<div className="input-container">
<div className="input-wrapper">
<textarea
value={message}
onChange={(event) => onMessageChange(event.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
rows={1}
disabled={disabled}
/>
<button className="send-button" onClick={onSendMessage} disabled={disabled || !message.trim()}>
<Send />
</button>
</div>
</div>
);
};
export default ChatInput;

View file

@ -1,375 +0,0 @@
import { useState } from "react";
import { getMessageClass } from "./messageUtils";
import type { TimelineEntry } from "./types";
import { AlertTriangle, ChevronRight, ChevronDown, Wrench, Brain, Info, ExternalLink, PlayCircle, Shield, Check, X } from "lucide-react";
import MarkdownText from "./MarkdownText";
const ToolItem = ({
entry,
isLast,
onEventClick
}: {
entry: TimelineEntry;
isLast: boolean;
onEventClick?: (eventId: string) => void;
}) => {
const [expanded, setExpanded] = useState(false);
const isTool = entry.kind === "tool";
const isReasoning = entry.kind === "reasoning";
const isMeta = entry.kind === "meta";
const isComplete = isTool && (entry.toolStatus === "completed" || entry.toolStatus === "failed");
const isFailed = isTool && entry.toolStatus === "failed";
const isInProgress = isTool && entry.toolStatus === "in_progress";
let label = "";
let icon = <Info size={12} />;
if (isTool) {
const statusLabel = entry.toolStatus && entry.toolStatus !== "completed"
? ` (${entry.toolStatus.replace("_", " ")})`
: "";
label = `${entry.toolName ?? "tool"}${statusLabel}`;
icon = <Wrench size={12} />;
} else if (isReasoning) {
label = `Reasoning${entry.reasoning?.visibility ? ` (${entry.reasoning.visibility})` : ""}`;
icon = <Brain size={12} />;
} else if (isMeta) {
label = entry.meta?.title ?? "Status";
icon = entry.meta?.severity === "error" ? <AlertTriangle size={12} /> : <Info size={12} />;
}
const hasContent = isTool
? Boolean(entry.toolInput || entry.toolOutput)
: isReasoning
? Boolean(entry.reasoning?.text?.trim())
: Boolean(entry.meta?.detail?.trim());
const canOpenEvent = Boolean(
entry.eventId &&
onEventClick &&
!(isMeta && entry.meta?.title === "Available commands update"),
);
return (
<div className={`tool-item ${isLast ? "last" : ""} ${isFailed ? "failed" : ""}`}>
<div className="tool-item-connector">
<div className="tool-item-dot" />
{!isLast && <div className="tool-item-line" />}
</div>
<div className="tool-item-content">
<button
className={`tool-item-header ${expanded ? "expanded" : ""}`}
onClick={() => hasContent && setExpanded(!expanded)}
disabled={!hasContent}
>
<span className="tool-item-icon">{icon}</span>
<span className="tool-item-label">{label}</span>
{isInProgress && (
<span className="tool-item-spinner">
<span className="thinking-dot" />
<span className="thinking-dot" />
<span className="thinking-dot" />
</span>
)}
{canOpenEvent && (
<span
className="tool-item-link"
onClick={(e) => {
e.stopPropagation();
onEventClick?.(entry.eventId!);
}}
title="View in Events"
>
<ExternalLink size={10} />
</span>
)}
{hasContent && (
<span className="tool-item-chevron">
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</span>
)}
</button>
{expanded && hasContent && (
<div className="tool-item-body">
{isTool && entry.toolInput && (
<div className="tool-section">
<div className="tool-section-title">Input</div>
<pre className="tool-code">{entry.toolInput}</pre>
</div>
)}
{isTool && isComplete && entry.toolOutput && (
<div className="tool-section">
<div className="tool-section-title">Output</div>
<pre className="tool-code">{entry.toolOutput}</pre>
</div>
)}
{isReasoning && entry.reasoning?.text && (
<div className="tool-section">
<pre className="tool-code muted">{entry.reasoning.text}</pre>
</div>
)}
{isMeta && entry.meta?.detail && (
<div className="tool-section">
<pre className="tool-code">{entry.meta.detail}</pre>
</div>
)}
</div>
)}
</div>
</div>
);
};
const ToolGroup = ({ entries, onEventClick }: { entries: TimelineEntry[]; onEventClick?: (eventId: string) => void }) => {
const [expanded, setExpanded] = useState(false);
// If only one item, render it directly without macro wrapper
if (entries.length === 1) {
return (
<div className="tool-group-single">
<ToolItem entry={entries[0]} isLast={true} onEventClick={onEventClick} />
</div>
);
}
const totalCount = entries.length;
const summary = `${totalCount} Event${totalCount > 1 ? "s" : ""}`;
// Check if any are in progress
const hasInProgress = entries.some(e => e.kind === "tool" && e.toolStatus === "in_progress");
const hasFailed = entries.some(e => e.kind === "tool" && e.toolStatus === "failed");
return (
<div className={`tool-group-container ${hasFailed ? "failed" : ""}`}>
<button
className={`tool-group-header ${expanded ? "expanded" : ""}`}
onClick={() => setExpanded(!expanded)}
>
<span className="tool-group-icon">
<PlayCircle size={14} />
</span>
<span className="tool-group-label">{summary}</span>
<span className="tool-group-chevron">
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
</button>
{expanded && (
<div className="tool-group">
{entries.map((entry, idx) => (
<ToolItem
key={entry.id}
entry={entry}
isLast={idx === entries.length - 1}
onEventClick={onEventClick}
/>
))}
</div>
)}
</div>
);
};
const PermissionPrompt = ({
entry,
onPermissionReply,
}: {
entry: TimelineEntry;
onPermissionReply?: (permissionId: string, reply: "once" | "always" | "reject") => void;
}) => {
const perm = entry.permission;
if (!perm) return null;
const resolved = perm.resolved;
const selectedId = perm.selectedOptionId;
const replyForOption = (kind: string): "once" | "always" | "reject" => {
if (kind === "allow_once") return "once";
if (kind === "allow_always") return "always";
return "reject";
};
const labelForKind = (kind: string, name: string): string => {
if (name) return name;
if (kind === "allow_once") return "Allow Once";
if (kind === "allow_always") return "Always Allow";
if (kind === "reject_once") return "Reject";
if (kind === "reject_always") return "Reject Always";
return kind;
};
const classForKind = (kind: string): string => {
if (kind.startsWith("allow")) return "allow";
return "reject";
};
return (
<div className={`permission-prompt ${resolved ? "resolved" : ""}`}>
<div className="permission-header">
<Shield size={14} className="permission-icon" />
<span className="permission-title">{perm.title}</span>
</div>
{perm.description && (
<div className="permission-description">{perm.description}</div>
)}
<div className="permission-actions">
{perm.options.map((opt) => {
const isSelected = resolved && selectedId === opt.optionId;
const wasRejected = resolved && !isSelected && selectedId != null;
return (
<button
key={opt.optionId}
type="button"
className={`permission-btn ${classForKind(opt.kind)} ${isSelected ? "selected" : ""} ${wasRejected ? "dimmed" : ""}`}
disabled={resolved}
onClick={() => onPermissionReply?.(perm.permissionId, replyForOption(opt.kind))}
>
{isSelected && (opt.kind.startsWith("allow") ? <Check size={12} /> : <X size={12} />)}
{labelForKind(opt.kind, opt.name)}
</button>
);
})}
{resolved && !selectedId && (
<span className="permission-auto-resolved">Auto-resolved</span>
)}
</div>
</div>
);
};
const agentLogos: Record<string, string> = {
claude: `${import.meta.env.BASE_URL}logos/claude.svg`,
codex: `${import.meta.env.BASE_URL}logos/openai.svg`,
opencode: `${import.meta.env.BASE_URL}logos/opencode.svg`,
amp: `${import.meta.env.BASE_URL}logos/amp.svg`,
pi: `${import.meta.env.BASE_URL}logos/pi.svg`,
};
const ChatMessages = ({
entries,
sessionError,
eventError,
messagesEndRef,
onEventClick,
isThinking,
agentId,
onPermissionReply,
}: {
entries: TimelineEntry[];
sessionError: string | null;
eventError?: string | null;
messagesEndRef: React.RefObject<HTMLDivElement>;
onEventClick?: (eventId: string) => void;
isThinking?: boolean;
agentId?: string;
onPermissionReply?: (permissionId: string, reply: "once" | "always" | "reject") => void;
}) => {
// Group consecutive tool/reasoning/meta entries together
const groupedEntries: Array<{ type: "message" | "tool-group" | "divider" | "permission"; entries: TimelineEntry[] }> = [];
let currentToolGroup: TimelineEntry[] = [];
const flushToolGroup = () => {
if (currentToolGroup.length > 0) {
groupedEntries.push({ type: "tool-group", entries: currentToolGroup });
currentToolGroup = [];
}
};
for (const entry of entries) {
const isStatusDivider = entry.kind === "meta" &&
["Session Started", "Turn Started", "Turn Ended"].includes(entry.meta?.title ?? "");
if (entry.kind === "permission") {
flushToolGroup();
groupedEntries.push({ type: "permission", entries: [entry] });
} else if (isStatusDivider) {
flushToolGroup();
groupedEntries.push({ type: "divider", entries: [entry] });
} else if (entry.kind === "tool" || entry.kind === "reasoning" || (entry.kind === "meta" && entry.meta?.detail)) {
currentToolGroup.push(entry);
} else if (entry.kind === "meta" && !entry.meta?.detail) {
// Simple meta without detail - add to tool group as single item
currentToolGroup.push(entry);
} else {
// Regular message
flushToolGroup();
groupedEntries.push({ type: "message", entries: [entry] });
}
}
flushToolGroup();
return (
<div className="messages">
{groupedEntries.map((group, idx) => {
if (group.type === "divider") {
const entry = group.entries[0];
const title = entry.meta?.title ?? "Status";
return (
<div key={entry.id} className="status-divider">
<div className="status-divider-line" />
<span className="status-divider-text">{title}</span>
<div className="status-divider-line" />
</div>
);
}
if (group.type === "permission") {
const entry = group.entries[0];
return (
<PermissionPrompt
key={entry.id}
entry={entry}
onPermissionReply={onPermissionReply}
/>
);
}
if (group.type === "tool-group") {
return <ToolGroup key={`group-${idx}`} entries={group.entries} onEventClick={onEventClick} />;
}
// Regular message
const entry = group.entries[0];
const messageClass = getMessageClass(entry);
return (
<div key={entry.id} className={`message ${messageClass} no-avatar`}>
<div className="message-content">
{entry.text ? (
<MarkdownText text={entry.text} />
) : (
<span className="thinking-indicator">
<span className="thinking-dot" />
<span className="thinking-dot" />
<span className="thinking-dot" />
</span>
)}
</div>
</div>
);
})}
{sessionError && <div className="message-error">{sessionError}</div>}
{eventError && <div className="message-error">{eventError}</div>}
{isThinking && (
<div className="thinking-row">
<div className="thinking-avatar">
{agentId && agentLogos[agentId] ? (
<img src={agentLogos[agentId]} alt="" className="thinking-avatar-img" />
) : (
<span className="ai-label">AI</span>
)}
</div>
<span className="thinking-indicator">
<span className="thinking-dot" />
<span className="thinking-dot" />
<span className="thinking-dot" />
</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
);
};
export default ChatMessages;

View file

@ -1,3 +1,4 @@
import type { TranscriptEntry } from "@sandbox-agent/react";
import { AlertTriangle, Archive, CheckSquare, MessageSquare, Plus, Square, Terminal } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { AgentInfo } from "sandbox-agent";
@ -6,9 +7,7 @@ import { formatShortId } from "../../utils/format";
type AgentModeInfo = { id: string; name: string; description: string };
type AgentModelInfo = { id: string; name?: string };
import SessionCreateMenu, { type SessionConfig } from "../SessionCreateMenu";
import ChatInput from "./ChatInput";
import ChatMessages from "./ChatMessages";
import type { TimelineEntry } from "./types";
import InspectorConversation from "./InspectorConversation";
const HistoryLoadingSkeleton = () => (
<div className="chat-loading-skeleton" aria-hidden>
@ -60,7 +59,7 @@ const ChatPanel = ({
onPermissionReply,
}: {
sessionId: string;
transcriptEntries: TimelineEntry[];
transcriptEntries: TranscriptEntry[];
isLoadingHistory?: boolean;
sessionError: string | null;
message: string;
@ -214,8 +213,8 @@ const ChatPanel = ({
</div>
)}
<div className="messages-container">
{!sessionId ? (
{!sessionId ? (
<div className="messages-container">
<div className="empty-state">
<div className="empty-state-title">No Session Selected</div>
<p className="empty-state-text no-session-subtext">Create a new session to start chatting with an agent.</p>
@ -241,38 +240,36 @@ const ChatPanel = ({
/>
</div>
</div>
) : transcriptEntries.length === 0 && !sessionError ? (
isLoadingHistory ? (
<HistoryLoadingSkeleton />
) : (
<div className="empty-state">
<Terminal className="empty-state-icon" />
<div className="empty-state-title">Ready to Chat</div>
<p className="empty-state-text">Send a message to start a conversation with the agent.</p>
</div>
)
) : (
<ChatMessages
entries={transcriptEntries}
sessionError={sessionError}
eventError={null}
messagesEndRef={messagesEndRef}
onEventClick={onEventClick}
isThinking={isThinking}
agentId={agentId}
onPermissionReply={onPermissionReply}
/>
)}
</div>
<ChatInput
message={message}
onMessageChange={onMessageChange}
onSendMessage={onSendMessage}
onKeyDown={onKeyDown}
placeholder={sessionEnded ? "Session ended" : sessionId ? "Send a message..." : "Select or create a session first"}
disabled={!sessionId || sessionEnded}
/>
</div>
) : (
<InspectorConversation
entries={transcriptEntries}
sessionError={sessionError}
eventError={null}
messagesEndRef={messagesEndRef}
onEventClick={onEventClick}
isThinking={isThinking}
agentId={agentId}
emptyState={
isLoadingHistory ? (
<HistoryLoadingSkeleton />
) : (
<div className="empty-state">
<Terminal className="empty-state-icon" />
<div className="empty-state-title">Ready to Chat</div>
<p className="empty-state-text">Send a message to start a conversation with the agent.</p>
</div>
)
}
message={message}
onMessageChange={onMessageChange}
onSendMessage={onSendMessage}
onKeyDown={onKeyDown}
placeholder={sessionEnded ? "Session ended" : "Send a message..."}
disabled={sessionEnded}
onPermissionReply={onPermissionReply}
/>
)}
</div>
);
};

View file

@ -0,0 +1,184 @@
import {
AgentConversation,
type AgentConversationClassNames,
type AgentTranscriptClassNames,
type ChatComposerClassNames,
type PermissionReply,
type TranscriptEntry,
} from "@sandbox-agent/react";
import { AlertTriangle, Brain, Check, ChevronDown, ChevronRight, ExternalLink, Info, PlayCircle, Send, Shield, Wrench, X } from "lucide-react";
import type { ReactNode } from "react";
import MarkdownText from "./MarkdownText";
const agentLogos: Record<string, string> = {
claude: `${import.meta.env.BASE_URL}logos/claude.svg`,
codex: `${import.meta.env.BASE_URL}logos/openai.svg`,
opencode: `${import.meta.env.BASE_URL}logos/opencode.svg`,
amp: `${import.meta.env.BASE_URL}logos/amp.svg`,
pi: `${import.meta.env.BASE_URL}logos/pi.svg`,
};
const transcriptClassNames: Partial<AgentTranscriptClassNames> = {
root: "messages",
divider: "status-divider",
dividerLine: "status-divider-line",
dividerText: "status-divider-text",
message: "message",
messageContent: "message-content",
error: "message-error",
toolGroupSingle: "tool-group-single",
toolGroupContainer: "tool-group-container",
toolGroupHeader: "tool-group-header",
toolGroupIcon: "tool-group-icon",
toolGroupLabel: "tool-group-label",
toolGroupChevron: "tool-group-chevron",
toolGroupBody: "tool-group",
toolItem: "tool-item",
toolItemConnector: "tool-item-connector",
toolItemDot: "tool-item-dot",
toolItemLine: "tool-item-line",
toolItemContent: "tool-item-content",
toolItemHeader: "tool-item-header",
toolItemIcon: "tool-item-icon",
toolItemLabel: "tool-item-label",
toolItemSpinner: "tool-item-spinner",
toolItemLink: "tool-item-link",
toolItemChevron: "tool-item-chevron",
toolItemBody: "tool-item-body",
toolSection: "tool-section",
toolSectionTitle: "tool-section-title",
toolCode: "tool-code",
toolCodeMuted: "muted",
permissionPrompt: "permission-prompt",
permissionHeader: "permission-header",
permissionIcon: "permission-icon",
permissionTitle: "permission-title",
permissionDescription: "permission-description",
permissionActions: "permission-actions",
permissionButton: "permission-btn",
permissionAutoResolved: "permission-auto-resolved",
thinkingRow: "thinking-row",
thinkingIndicator: "thinking-indicator",
};
const conversationClassNames: Partial<AgentConversationClassNames> = {
root: "chat-conversation",
transcript: "messages-container",
};
const composerClassNames: Partial<ChatComposerClassNames> = {
root: "input-container",
form: "input-wrapper",
submit: "send-button",
};
const ThinkingDots = () => (
<>
<span className="thinking-dot" />
<span className="thinking-dot" />
<span className="thinking-dot" />
</>
);
export interface InspectorConversationProps {
entries: TranscriptEntry[];
sessionError: string | null;
eventError?: string | null;
messagesEndRef: React.RefObject<HTMLDivElement>;
onEventClick?: (eventId: string) => void;
isThinking?: boolean;
agentId?: string;
emptyState?: ReactNode;
message: string;
onMessageChange: (value: string) => void;
onSendMessage: () => void;
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
placeholder: string;
disabled: boolean;
onPermissionReply?: (permissionId: string, reply: PermissionReply) => void;
}
const InspectorConversation = ({
entries,
sessionError,
eventError,
messagesEndRef,
onEventClick,
isThinking,
agentId,
emptyState,
message,
onMessageChange,
onSendMessage,
onKeyDown,
placeholder,
disabled,
onPermissionReply,
}: InspectorConversationProps) => {
return (
<AgentConversation
entries={entries}
classNames={conversationClassNames}
emptyState={emptyState}
transcriptClassNames={transcriptClassNames}
transcriptProps={{
endRef: messagesEndRef,
sessionError,
eventError,
onEventClick,
isThinking,
agentId,
canOpenEvent: (entry) => !(entry.kind === "meta" && entry.meta?.title === "Available commands update"),
renderMessageText: (entry) => <MarkdownText text={entry.text ?? ""} />,
renderInlinePendingIndicator: () => <ThinkingDots />,
renderToolItemIcon: (entry) => {
if (entry.kind === "tool") {
return <Wrench size={12} />;
}
if (entry.kind === "reasoning") {
return <Brain size={12} />;
}
return entry.meta?.severity === "error" ? <AlertTriangle size={12} /> : <Info size={12} />;
},
renderToolGroupIcon: () => <PlayCircle size={14} />,
renderChevron: (expanded) => (expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />),
renderEventLinkContent: () => <ExternalLink size={10} />,
onPermissionReply,
renderPermissionIcon: () => <Shield size={14} />,
renderPermissionOptionContent: ({ option, label, selected }) => (
<>
{selected ? (option.kind.startsWith("allow") ? <Check size={12} /> : <X size={12} />) : null}
{label}
</>
),
renderThinkingState: ({ agentId: activeAgentId }) => (
<div className="thinking-row">
<div className="thinking-avatar">
{activeAgentId && agentLogos[activeAgentId] ? (
<img src={agentLogos[activeAgentId]} alt="" className="thinking-avatar-img" />
) : (
<span className="ai-label">AI</span>
)}
</div>
<span className="thinking-indicator">
<ThinkingDots />
</span>
</div>
),
}}
composerClassNames={composerClassNames}
composerProps={{
message,
onMessageChange,
onSubmit: onSendMessage,
onKeyDown,
placeholder,
disabled,
submitLabel: "Send",
renderSubmitContent: () => <Send />,
}}
/>
);
};
export default InspectorConversation;

View file

@ -1,19 +0,0 @@
import type { TimelineEntry } from "./types";
import { Settings, AlertTriangle } from "lucide-react";
import type { ReactNode } from "react";
export const getMessageClass = (entry: TimelineEntry) => {
if (entry.kind === "tool") return "tool";
if (entry.kind === "meta") return entry.meta?.severity === "error" ? "error" : "system";
if (entry.kind === "reasoning") return "assistant";
if (entry.role === "user") return "user";
return "assistant";
};
export const getAvatarLabel = (messageClass: string): ReactNode => {
if (messageClass === "user") return null;
if (messageClass === "tool") return "T";
if (messageClass === "system") return <Settings size={14} />;
if (messageClass === "error") return <AlertTriangle size={14} />;
return "AI";
};

View file

@ -1,33 +0,0 @@
export type PermissionOption = {
optionId: string;
name: string;
kind: string;
};
export type TimelineEntry = {
id: string;
eventId?: string; // Links back to the original event for navigation
kind: "message" | "tool" | "meta" | "reasoning" | "permission";
time: string;
// For messages:
role?: "user" | "assistant";
text?: string;
// For tool calls:
toolName?: string;
toolInput?: string;
toolOutput?: string;
toolStatus?: string;
// For reasoning:
reasoning?: { text: string; visibility?: string };
// For meta:
meta?: { title: string; detail?: string; severity?: "info" | "error" };
// For permission requests:
permission?: {
permissionId: string;
title: string;
description?: string;
options: PermissionOption[];
resolved?: boolean;
selectedOptionId?: string;
};
};