import { graphlib, layout as layoutDagreGraph } from "@dagrejs/dagre";
import {
Background,
BackgroundVariant,
Controls,
Handle,
MarkerType,
MiniMap,
Position,
ReactFlow,
type Edge,
type Node as FlowNode,
type NodeProps,
type NodeTypes,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import {
createContext,
Fragment,
isValidElement,
memo,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
type ClipboardEvent,
type CSSProperties,
type DragEvent,
type FormEvent,
type ReactNode,
type RefObject,
type UIEvent,
} from "react";
import { createPortal } from "react-dom";
import {
ArrowLeftIcon,
ArrowDownIcon,
AlertTriangleIcon,
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
CopyIcon,
CommandIcon,
CornerDownLeftIcon,
GripVerticalIcon,
ImagePlusIcon,
InfoIcon,
Maximize2Icon,
MoreHorizontalIcon,
PencilIcon,
SendHorizontalIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import {
AgentExpression,
type AgentExpressionStatus,
} from "@/components/agent-expression";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
CollapsibleTrigger,
useCollapsibleState,
} from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
InputGroup,
InputGroupAddon,
InputGroupTextarea,
} from "@/components/ui/input-group";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
fetchDashboardActivityHistory,
fetchWorkflowWorkerActivity,
fetchSettingsSummary,
getDashboardAttachmentUrl,
runDashboardAction,
runDashboardCommand,
type DashboardAction,
type DashboardActionResult,
type DashboardActivityHistoryItem,
type DashboardActivityHistoryPage,
type DashboardCommandAttachment,
type DashboardPendingUserInput,
type DashboardSnapshot,
type DashboardPlanStep,
type TokenUsageInfo,
type SessionActivityEvent,
type WorkflowAwaitGroupSnapshot,
type WorkflowNodeStatus,
type WorkflowRunSnapshot,
type WorkflowTransitionKind,
type WorkflowTransitionSnapshot,
type WorkflowWorkerActivityPage,
type WorkflowWorkerSnapshot,
} from "@/lib/daemon-api";
import {
foldCompletedAgentChatActivity,
type AgentChatFoldDisplayItem,
} from "@/lib/agent-chat-folding";
import { normalizeThinkingMarkdown } from "@/lib/agent-chat-markdown";
import {
highlightCodeWithShiki,
type ShikiColorScheme,
type ShikiHighlightedCode,
type ShikiHighlightToken,
} from "@/lib/shiki-highlight";
import { useDashboardSnapshot } from "@/hooks/use-dashboard-snapshot";
import { cn } from "@/lib/utils";
export { StatusPage } from "@/components/status-dashboard-page";
const AGENT_CHAT_HISTORY_PAGE_LIMIT = 80;
const AGENT_CHAT_NAV_HISTORY_PAGE_LIMIT = 40;
const AGENT_CHAT_QUICK_NAV_MAX_ITEMS = 120;
const AGENT_CHAT_MESSAGE_LINE_LIMIT = 5;
const AGENT_CHAT_ACTIVITY_BLOCK_LINE_LIMIT = 12;
const AGENT_CHAT_FULL_MESSAGE_LINE_LIMIT = Number.MAX_SAFE_INTEGER;
const AGENT_CHAT_PLAN_STEP_LIMIT = 8;
const AGENT_CHAT_TERMINAL_OUTPUT_HEAD_LINES = 4;
const AGENT_CHAT_TERMINAL_OUTPUT_TAIL_LINES = 4;
const AGENT_CHAT_TELEGRAM_DETAIL_LIMIT = 6;
const AGENT_CHAT_TELEGRAM_MESSAGE_LIMIT = 6;
const AGENT_CHAT_TERMINAL_WAIT_LINE_LIMIT = 6;
const AGENT_CHAT_ERROR_LINE_LIMIT = 12;
const AGENT_CHAT_THINKING_PREVIEW_LINE_LIMIT = 3;
const AGENT_CHAT_STICKY_BOTTOM_THRESHOLD_PX = 72;
const AGENT_CHAT_SCROLL_BUTTON_THRESHOLD_PX = 160;
const AGENT_CHAT_MAX_IMAGE_ATTACHMENTS = 4;
const AGENT_CHAT_MAX_IMAGE_ATTACHMENT_BYTES = 10 * 1024 * 1024;
const AGENT_CHAT_RUNTIME_SHIMMER_MS = 2_000;
const AGENT_CHAT_RUNTIME_SHIMMER_STAGGER_MS = 120;
const AGENT_CHAT_INLINE_PREVIEW_MAX_BYTES = 2 * 1024 * 1024;
const AGENT_CHAT_COMPOSER_DEFAULT_HEIGHT_PX = 60;
const AGENT_CHAT_COMPOSER_BOTTOM_GAP_PX = 16;
const AGENT_CHAT_PENDING_INPUT_VISIBLE_DELAY_MS = 200;
const AGENT_CHAT_MAX_QUEUED_INPUTS = 5;
const AGENT_CHAT_QUICK_NAV_COLLAPSED_ITEM_LIMIT = 14;
const AGENT_CHAT_QUICK_NAV_SCROLL_OFFSET_PX = 16;
export function AgentPage({
sessionId,
mockSnapshot,
}: {
sessionId: string;
mockSnapshot?: DashboardSnapshot;
}) {
const { snapshot } = useDashboardSnapshot(sessionId, {
disabled: Boolean(mockSnapshot),
initialSnapshot: mockSnapshot ?? null,
});
const chatPanelRef = useRef<HTMLDivElement>(null);
const [chatComposerHeight, setChatComposerHeight] = useState(
AGENT_CHAT_COMPOSER_DEFAULT_HEIGHT_PX,
);
const [supportsVision, setSupportsVision] = useState(true);
useEffect(() => {
if (mockSnapshot) {
setSupportsVision(true);
return;
}
const controller = new AbortController();
void (async () => {
try {
const summary = await fetchSettingsSummary({
signal: controller.signal,
});
const mainModel = summary.models.find((m) => m.is_main);
if (mainModel) {
setSupportsVision(mainModel.supports_vision);
}
} catch {
// If settings fetch fails, keep default (true) so image button stays visible.
}
})();
return () => controller.abort();
}, [mockSnapshot]);
return (
<AgentChatMockDataContext.Provider value={Boolean(mockSnapshot)}>
<section
id="agent"
aria-label="Agent"
className="relative flex h-screen min-h-screen w-full max-w-full flex-col overflow-hidden bg-background"
>
<AgentChatBubbles
sessionId={sessionId}
snapshot={snapshot}
panelRef={chatPanelRef}
composerHeight={chatComposerHeight}
/>
<AgentChatComposer
sessionId={sessionId}
snapshot={snapshot}
agentName={snapshot?.agent_name}
supportsVision={supportsVision}
chatPanelRef={chatPanelRef}
onHeightChange={setChatComposerHeight}
/>
</section>
</AgentChatMockDataContext.Provider>
);
}
type AgentChatBubbleRole =
| "assistant"
| "user"
| "tool"
| "telegram"
| "system";
type AgentChatTextBlock = {
type: "text";
text: string;
};
type AgentChatCodeBlock = {
type: "code";
code: string;
language?: string | null;
};
type AgentChatKvBlock = {
type: "kv";
entries: Array<{
key: string;
value: string;
}>;
};
type AgentChatListBlock = {
type: "list";
items: string[];
};
type AgentChatDiffBlock = {
type: "diff";
files: Array<{
path: string;
operation: string;
added_lines: number;
removed_lines: number;
lines: Array<{
kind: "context" | "delete" | "add" | "hunk_break" | (string & {});
old_lineno?: number | null;
new_lineno?: number | null;
text: string;
}>;
}>;
};
type AgentChatLinkBlock = {
type: "link";
label: string;
url: string;
};
type AgentChatArtifactBlock = {
type: "artifact";
label: string;
uri?: string | null;
mime_type?: string | null;
};
type AgentChatImageBlock = {
type: "image";
label: string;
uri: string;
mime_type?: string | null;
};
type AgentChatUnknownBlock = {
type: string;
[key: string]: unknown;
};
type AgentChatBlock =
| AgentChatTextBlock
| AgentChatCodeBlock
| AgentChatKvBlock
| AgentChatListBlock
| AgentChatDiffBlock
| AgentChatLinkBlock
| AgentChatArtifactBlock
| AgentChatImageBlock
| AgentChatUnknownBlock;
type AgentChatBubble = {
id: string;
role: AgentChatBubbleRole;
kind: string;
status: string;
uiHint?: string | null;
title: string;
createdAt: number;
updatedAt: number;
blocks: AgentChatBlock[];
planSteps: AgentChatPlanStep[];
live?: boolean;
toolName?: string;
appName?: string;
sourceLabel?: string;
activityEvent?: SessionActivityEvent | null;
};
type AgentChatActivityItem = {
id: string;
kind: string;
status: string;
ui_hint?: string | null;
title: string;
actor?: string | null;
created_at: number;
updated_at: number;
source?: Record<string, unknown> | null;
tool?: Record<string, unknown> | null;
blocks?: AgentChatBlock[];
detail_blocks?: AgentChatBlock[];
error?: {
message: string;
details?: string[];
} | null;
metadata?: unknown;
activityEvent?: SessionActivityEvent | null;
};
type AgentChatQuickNavItem = {
id: string;
label: string;
order: number;
};
type AgentChatQuickNavDisplayTarget = {
id: string;
quickNavItemId: string;
};
type AgentChatPlanStepStatus =
| "pending"
| "in_progress"
| "completed"
| "unknown";
type AgentChatPlanStep = {
status: AgentChatPlanStepStatus;
text: string;
};
type AgentChatImageAttachmentData = {
label: string;
uri: string;
mimeType: string;
};
type AgentChatActivityMarkerKind =
| "activity"
| "error"
| "user";
type AgentChatPendingImageAttachment = {
id: string;
file: File;
previewUrl?: string;
};
type WebSlashCommandLevel = "info" | "warning" | "error";
type WebInputSuggestion = {
display: string;
completion: string;
description: string;
};
type WebSlashCommandFeedback = {
title: string;
message: string;
detail?: string;
level: WebSlashCommandLevel;
blocksSubmit?: boolean;
dismissible?: boolean;
};
type WebSlashPanel =
| {
kind: "selection";
panel: "debug" | "sleep" | "skills" | "app-status" | "workflows";
}
| {
kind: "detail";
title: string;
text: string;
}
| {
kind: "status";
}
| {
kind: "sleep-status";
}
| {
kind: "skills-list";
search: string;
}
| {
kind: "skills-toggle";
search: string;
feedback?: WebSlashCommandFeedback | null;
}
| {
kind: "workflow-form";
workflowId: string;
values: Record<string, string>;
feedback?: WebSlashCommandFeedback | null;
};
type WebSlashSelectionItem = {
id: string;
name: string;
description: string;
disabled?: boolean;
};
type WebSlashActionFeedback = WebSlashCommandFeedback & {
command?: string;
};
type WebSlashCommandDefinition = {
name: string;
description: string;
aliases?: string[];
};
const WEB_SLASH_COMMANDS: WebSlashCommandDefinition[] = [
{
name: "status",
description: "show overall status",
},
{
name: "clear",
description: "clear runtime conversation, plan, events, and activity",
},
{
name: "debug",
description: "debug outputs and internal runtime views",
},
{
name: "app-status",
description: "show current structured app state and llm-facing note",
aliases: ["app_status"],
},
{
name: "restart",
description: "restart the daemon",
},
{
name: "sleep",
description: "sleep controls and status",
},
{
name: "skills",
description: "list and manage OpenSkills automatic use",
},
{
name: "workflows",
description: "browse loaded Lua workflows and run typed inputs",
},
];
type AgentChatSessionActivityRender =
| {
kind: "text";
icon: AgentChatActivityMarkerKind;
title: string;
bodyLines: string[];
fullBody?: string | null;
imageAttachments?: AgentChatImageAttachmentData[];
bodyLimit?: number;
tone?: "default" | "error" | "muted";
preserveSoftBreaks?: boolean;
}
| {
kind: "browser";
icon: AgentChatActivityMarkerKind;
title: string;
detailLines: string[];
detailLimit?: number;
}
| {
kind: "plan";
icon: AgentChatActivityMarkerKind;
title: string;
steps: AgentChatPlanStep[];
}
| {
kind: "primitive";
icon: AgentChatActivityMarkerKind;
title: string;
primitiveId: string;
}
| {
kind: "exec";
icon: AgentChatActivityMarkerKind;
title: string;
outputLines: string[];
running?: boolean;
exitCode?: number | null;
}
| {
kind: "explored";
icon: AgentChatActivityMarkerKind;
title: string;
calls: AgentChatExploredCall[];
}
| {
kind: "patch";
icon: AgentChatActivityMarkerKind;
title: string;
files: AgentChatDiffFile[];
}
| {
kind: "messageActivity";
icon: AgentChatActivityMarkerKind;
title: string;
detailLines: string[];
messageLines: string[];
detailLimit: number;
messageLimit: number;
}
| {
kind: "reply";
title: string;
messageLines: string[];
disposition: string;
subject: string;
}
| {
kind: "thinking";
content: string;
bodyLimit: number;
}
| {
kind: "runtimeStatus";
icon: AgentChatActivityMarkerKind;
title: string;
detail?: string | null;
startedAtMs?: number | null;
reducedMotion?: string | null;
}
| {
kind: "workflow";
icon: AgentChatActivityMarkerKind;
workflowId: string;
status: string;
output?: unknown | null;
message: string;
snapshot?: WorkflowRunSnapshot | null;
};
type AgentChatExploredCallAction = "read" | "list" | "search" | "run" | "unknown";
type AgentChatExploredCall = {
toolName: string;
action: AgentChatExploredCallAction;
target: string | null;
secondaryTarget: string | null;
summary: string;
detailLines: string[];
detailTitle: string | null;
};
type AgentChatSessionActivityViewProps = {
sessionId?: string;
bubbleId: string;
render: AgentChatSessionActivityRender;
isLatestReply?: boolean;
isActiveRuntimeStatus?: boolean;
onOpenWorkflowInspector?: (snapshot: WorkflowRunSnapshot) => void;
};
type AgentChatExpressionSlotKind = "reply" | "runtime";
type AgentChatExpressionRect = {
top: number;
left: number;
right: number;
bottom: number;
width: number;
height: number;
};
type AgentChatExpressionSlotMeta = {
status: AgentExpressionStatus;
className?: string;
};
type AgentChatExpressionSlotRegistration = AgentChatExpressionSlotMeta & {
element: HTMLElement;
};
type AgentChatExpressionSlotSnapshot = AgentChatExpressionSlotMeta & {
rect: AgentChatExpressionRect;
};
type AgentChatExpressionTransition = AgentChatExpressionSlotMeta & {
id: number;
toKey: string;
fromRect: AgentChatExpressionRect;
toRect: AgentChatExpressionRect;
};
type AgentChatExpressionTransitionContextValue = {
hiddenSlotKey: string | null;
registerSlot: (
slotKey: string,
element: HTMLElement | null,
meta: AgentChatExpressionSlotMeta,
) => void;
};
const AGENT_CHAT_EXPRESSION_TRANSITION_MS = 520;
const AgentChatExpressionTransitionContext =
createContext<AgentChatExpressionTransitionContextValue | null>(null);
const AgentChatMockDataContext = createContext(false);
function agentChatExpressionSlotKey(
kind: AgentChatExpressionSlotKind,
id: string,
) {
return `${kind}:${id}`;
}
function agentChatExpressionRectFromElement(
element: HTMLElement,
): AgentChatExpressionRect {
const rect = element.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
};
}
function agentChatExpressionRectIsVisible(rect: AgentChatExpressionRect) {
return (
rect.width > 0 &&
rect.height > 0 &&
rect.bottom > 0 &&
rect.right > 0 &&
rect.top < window.innerHeight &&
rect.left < window.innerWidth
);
}
function useAgentChatPrefersReducedMotion() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
const handleChange = () => setPrefersReducedMotion(mediaQuery.matches);
handleChange();
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, []);
return prefersReducedMotion;
}
function AgentChatComposer({
sessionId,
snapshot,
agentName,
supportsVision = true,
chatPanelRef,
onHeightChange,
}: {
sessionId: string;
snapshot: DashboardSnapshot | null;
agentName?: string;
supportsVision?: boolean;
chatPanelRef: RefObject<HTMLDivElement | null>;
onHeightChange: (height: number) => void;
}) {
const chatPlaceholder = `Chat with ${agentName?.trim() || "Agent"}`;
const formRef = useRef<HTMLFormElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [message, setMessage] = useState("");
const [imageAttachments, setImageAttachments] = useState<
AgentChatPendingImageAttachment[]
>([]);
const imageAttachmentsRef = useRef<AgentChatPendingImageAttachment[]>([]);
const nextImageAttachmentIdRef = useRef(0);
const [isSending, setIsSending] = useState(false);
const [isDraggingImage, setIsDraggingImage] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const [slashCommandSelection, setSlashCommandSelection] = useState(0);
const [slashPanel, setSlashPanel] = useState<WebSlashPanel | null>(null);
const [slashActionFeedback, setSlashActionFeedback] =
useState<WebSlashActionFeedback | null>(null);
const [pendingUserInputActionId, setPendingUserInputActionId] = useState<
string | null
>(null);
const pendingUserInputs = snapshot?.pending_user_inputs ?? [];
const visiblePendingUserInputs = useDelayedPendingUserInputs(
pendingUserInputs,
AGENT_CHAT_PENDING_INPUT_VISIBLE_DELAY_MS,
);
const queuedInputLimitReached =
pendingUserInputs.length >= AGENT_CHAT_MAX_QUEUED_INPUTS;
const queuedInputLimitMessage = `You can queue up to ${AGENT_CHAT_MAX_QUEUED_INPUTS} inputs. Wait for the agent to handle one or clear the queue.`;
const inputSuggestions = useMemo(
() => webInputSuggestions(message, snapshot),
[message, snapshot],
);
const slashCommandFeedback = useMemo(
() => webSlashCommandFeedback(message, snapshot, imageAttachments.length),
[imageAttachments.length, message, snapshot],
);
const selectedInputSuggestion =
inputSuggestions[Math.min(slashCommandSelection, inputSuggestions.length - 1)];
const slashCommandBlocksSubmit =
Boolean(slashCommandFeedback?.blocksSubmit) ||
(isWebSlashCommandInput(message) &&
!parseWebSlashCommand(message)?.trimmed);
const composerHasPayload = message.trim().length > 0 || imageAttachments.length > 0;
const composerQueuesUserInput = !isWebSlashCommandInput(message);
const queuedInputLimitBlocksSubmit =
queuedInputLimitReached && composerHasPayload && composerQueuesUserInput;
const isRuntimeInterruptible =
snapshot?.runtime_activity?.active_runtime_turn ?? false;
const sendButtonInterruptsRuntime =
isRuntimeInterruptible && !composerHasPayload;
const sendButtonDisabled =
isSending ||
(sendButtonInterruptsRuntime
? false
: !composerHasPayload ||
slashCommandBlocksSubmit ||
queuedInputLimitBlocksSubmit);
useEffect(() => {
setSlashCommandSelection((current) =>
Math.min(current, Math.max(0, inputSuggestions.length - 1)),
);
}, [inputSuggestions.length]);
useEffect(() => {
imageAttachmentsRef.current = imageAttachments;
}, [imageAttachments]);
useEffect(() => {
return () => {
for (const attachment of imageAttachmentsRef.current) {
revokeImagePreviewUrl(attachment);
}
};
}, []);
useEffect(() => {
const form = formRef.current;
if (!form) {
return;
}
const updateHeight = () => {
onHeightChange(Math.ceil(form.getBoundingClientRect().height));
};
updateHeight();
if (typeof ResizeObserver === "undefined") {
window.addEventListener("resize", updateHeight);
return () => window.removeEventListener("resize", updateHeight);
}
const resizeObserver = new ResizeObserver(updateHeight);
resizeObserver.observe(form);
return () => resizeObserver.disconnect();
}, [onHeightChange]);
const updateMessageTextareaHeight = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea || typeof window === "undefined") {
return;
}
const maxHeight = window.innerHeight * 0.3;
textarea.style.height = "auto";
const nextHeight = Math.min(textarea.scrollHeight, maxHeight);
textarea.style.height = `${nextHeight}px`;
textarea.style.overflowY =
textarea.scrollHeight > maxHeight ? "auto" : "hidden";
}, []);
useEffect(() => {
updateMessageTextareaHeight();
}, [message, updateMessageTextareaHeight]);
useEffect(() => {
window.addEventListener("resize", updateMessageTextareaHeight);
return () =>
window.removeEventListener("resize", updateMessageTextareaHeight);
}, [updateMessageTextareaHeight]);
function createPendingImageAttachment(
file: File,
): AgentChatPendingImageAttachment {
const nextId = nextImageAttachmentIdRef.current;
nextImageAttachmentIdRef.current += 1;
return {
id: `${file.name}-${file.size}-${file.lastModified}-${Date.now()}-${nextId}`,
file,
previewUrl: createImagePreviewUrl(file),
};
}
function addImageFiles(files: Iterable<File>) {
const nextFiles = Array.from(files).filter((file) =>
file.type.startsWith("image/"),
);
if (nextFiles.length === 0) {
return;
}
setImageAttachments((current) => {
const remainingSlots = Math.max(
0,
AGENT_CHAT_MAX_IMAGE_ATTACHMENTS - current.length,
);
const accepted = nextFiles.slice(0, remainingSlots);
const rejectedForCount = nextFiles.length - accepted.length;
const valid = accepted.filter(
(file) => file.size <= AGENT_CHAT_MAX_IMAGE_ATTACHMENT_BYTES,
);
const oversized = accepted.find(
(file) => file.size > AGENT_CHAT_MAX_IMAGE_ATTACHMENT_BYTES,
);
if (rejectedForCount > 0) {
setSendError(
`You can attach up to ${AGENT_CHAT_MAX_IMAGE_ATTACHMENTS} images.`,
);
} else if (oversized) {
setSendError(
`${oversized.name} is too large. Each image must be ${formatFileSize(
AGENT_CHAT_MAX_IMAGE_ATTACHMENT_BYTES,
)} or smaller.`,
);
} else if (valid.length > 0) {
setSendError(null);
}
return [
...current,
...valid.map((file) => createPendingImageAttachment(file)),
];
});
}
function removeImageAttachment(id: string) {
setImageAttachments((current) => {
const removed = current.find((attachment) => attachment.id === id);
if (removed) {
revokeImagePreviewUrl(removed);
}
return current.filter((attachment) => attachment.id !== id);
});
}
async function commandAttachmentsFromPendingImages(): Promise<
DashboardCommandAttachment[]
> {
return Promise.all(
imageAttachments.map(async (attachment) => ({
name: attachment.file.name || "image",
media_type: attachment.file.type || "application/octet-stream",
data_url: await readFileAsDataUrl(attachment.file),
})),
);
}
async function submitComposerInput(rawInput: string) {
const trimmed = rawInput.trim();
const isSlashCommand = isWebSlashCommandInput(trimmed);
const slashBodyMissing =
isSlashCommand && !parseWebSlashCommand(trimmed)?.trimmed;
const slashFeedback = isSlashCommand
? webSlashCommandFeedback(trimmed, snapshot, imageAttachments.length)
: null;
if (
(!trimmed && imageAttachments.length === 0) ||
isSending ||
slashBodyMissing ||
Boolean(slashFeedback?.blocksSubmit)
) {
return;
}
if (
!isSlashCommand &&
pendingUserInputs.length >= AGENT_CHAT_MAX_QUEUED_INPUTS
) {
setSendError(queuedInputLimitMessage);
return;
}
if (isSlashCommand) {
const panel = webSlashPanelForInput(trimmed, snapshot);
if (panel) {
setSlashPanel(panel);
setSlashActionFeedback(null);
setMessage("");
setSlashCommandSelection(0);
window.requestAnimationFrame(() => {
updateMessageTextareaHeight();
textareaRef.current?.focus();
});
return;
}
const action = webSlashActionForInput(trimmed, snapshot);
if (action) {
await runSlashDashboardAction(action, trimmed);
return;
}
}
setIsSending(true);
setSendError(null);
try {
const attachments = isSlashCommand
? []
: await commandAttachmentsFromPendingImages();
const output = await runDashboardCommand(trimmed, {
attachments,
sessionId,
});
setMessage("");
setImageAttachments((current) => {
for (const attachment of current) {
revokeImagePreviewUrl(attachment);
}
return [];
});
if (isSlashCommand) {
setSlashCommandSelection(0);
setSlashActionFeedback(webSlashActionFeedbackForResponse(trimmed, output));
}
window.requestAnimationFrame(() => {
if (chatPanelRef.current) {
chatPanelRef.current.scrollTop = chatPanelRef.current.scrollHeight;
}
});
} catch (error) {
setSendError(error instanceof Error ? error.message : String(error));
} finally {
setIsSending(false);
}
}
async function interruptRuntimeTurn(): Promise<boolean> {
if (isSending || !isRuntimeInterruptible) {
return false;
}
setIsSending(true);
setSendError(null);
setSlashActionFeedback(null);
try {
const result = await runDashboardAction(
{ kind: "interrupt_runtime" },
{ sessionId },
);
if (!result.success) {
setSendError(
result.detail ? `${result.message}: ${result.detail}` : result.message,
);
return false;
}
return true;
} catch (error) {
setSendError(error instanceof Error ? error.message : String(error));
return false;
} finally {
setIsSending(false);
}
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (sendButtonInterruptsRuntime) {
await interruptRuntimeTurn();
return;
}
await submitComposerInput(message);
}
function applyInputSuggestion(suggestion: WebInputSuggestion) {
setMessage(suggestion.completion);
setSendError(null);
setSlashCommandSelection(0);
setSlashActionFeedback(null);
window.requestAnimationFrame(() => {
updateMessageTextareaHeight();
textareaRef.current?.focus();
});
}
async function runSlashAction(command: string, detailTitle?: string) {
if (!detailTitle) {
await submitComposerInput(command);
return;
}
setIsSending(true);
setSendError(null);
setSlashActionFeedback(null);
try {
const output = await runDashboardCommand(command, {
attachments: [],
sessionId,
});
setSlashPanel({
kind: "detail",
title: detailTitle,
text: fallbackOutput(output),
});
} catch (error) {
setSendError(error instanceof Error ? error.message : String(error));
} finally {
setIsSending(false);
}
}
async function runSlashDashboardAction(
action: DashboardAction,
commandLabel?: string,
): Promise<DashboardActionResult | null> {
setIsSending(true);
setSendError(null);
setSlashActionFeedback(null);
try {
const result = await runDashboardAction(action, { sessionId });
setMessage("");
setImageAttachments((current) => {
for (const attachment of current) {
revokeImagePreviewUrl(attachment);
}
return [];
});
setSlashCommandSelection(0);
if (action.kind !== "run_workflow") {
setSlashActionFeedback(
webSlashActionFeedbackForResult(commandLabel ?? action.kind, result),
);
}
return result;
} catch (error) {
setSendError(error instanceof Error ? error.message : String(error));
return null;
} finally {
setIsSending(false);
}
}
async function runPendingUserInputAction(
action: DashboardAction,
actionId: string,
) {
if (pendingUserInputActionId) {
return;
}
setPendingUserInputActionId(actionId);
setSendError(null);
setSlashActionFeedback(null);
try {
const result = await runDashboardAction(action, { sessionId });
if (!result.success) {
setSendError(
result.detail ? `${result.message}: ${result.detail}` : result.message,
);
}
} catch (error) {
setSendError(error instanceof Error ? error.message : String(error));
} finally {
setPendingUserInputActionId(null);
}
}
function handlePaste(event: ClipboardEvent<HTMLTextAreaElement>) {
if (!supportsVision) {
return;
}
const files = Array.from(event.clipboardData.files).filter((file) =>
file.type.startsWith("image/"),
);
if (files.length > 0) {
addImageFiles(files);
}
}
function handleDrop(event: DragEvent<HTMLFormElement>) {
if (!supportsVision) {
return;
}
const files = Array.from(event.dataTransfer.files).filter((file) =>
file.type.startsWith("image/"),
);
if (files.length === 0) {
return;
}
event.preventDefault();
setIsDraggingImage(false);
addImageFiles(files);
}
return (
<form
ref={formRef}
aria-label="Send message to agent"
onSubmit={handleSubmit}
onDragEnter={(event) => {
if (!supportsVision) {
return;
}
if (hasImageDragItems(event.dataTransfer)) {
event.preventDefault();
setIsDraggingImage(true);
}
}}
onDragOver={(event) => {
if (!supportsVision) {
return;
}
if (hasImageDragItems(event.dataTransfer)) {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
setIsDraggingImage(true);
}
}}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as globalThis.Node | null)) {
setIsDraggingImage(false);
}
}}
onDrop={handleDrop}
className={cn(
"fixed inset-x-4 bottom-4 z-30 rounded-xl border bg-background/92 p-2 shadow-xl shadow-background/30 backdrop-blur-xl transition-all duration-300 md:right-auto md:left-[calc(18rem+(100vw-18rem)/2)] md:w-[min(56rem,calc(100vw-18rem-2rem))] md:-translate-x-1/2",
isDraggingImage && "border-primary/70 ring-4 ring-primary/15",
"border-border/70 focus-within:border-primary/45 focus-within:ring-4 focus-within:ring-primary/10 hover:border-primary/30",
)}
>
<WebSlashCommandPanel
panel={slashPanel}
snapshot={snapshot}
feedback={slashCommandFeedback}
actionFeedback={slashActionFeedback}
suggestions={inputSuggestions}
selectedSuggestionIndex={slashCommandSelection}
isSending={isSending}
onClosePanel={() => setSlashPanel(null)}
onSetPanel={setSlashPanel}
onCloseActionFeedback={() => setSlashActionFeedback(null)}
onSelectSuggestion={applyInputSuggestion}
onHoverSuggestion={setSlashCommandSelection}
onRunAction={(command) => void runSlashAction(command)}
onRunDashboardAction={runSlashDashboardAction}
/>
{supportsVision ? (
<Input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="sr-only"
aria-label="Attach images"
onChange={(event) => {
addImageFiles(event.target.files ?? []);
event.currentTarget.value = "";
}}
/>
) : null}
{imageAttachments.length > 0 ? (
<div className="flex gap-2 overflow-x-auto px-2 pb-2">
{imageAttachments.map((attachment) => (
<div
key={attachment.id}
className="group relative h-16 w-16 shrink-0 overflow-hidden rounded-xl border border-border/70 bg-muted"
title={`${attachment.file.name} · ${formatFileSize(attachment.file.size)}`}
>
{attachment.previewUrl ? (
<img
src={attachment.previewUrl}
alt={attachment.file.name || "Pending image attachment"}
className="h-full w-full object-cover"
/>
) : (
<div
aria-label={
attachment.file.name || "Pending image attachment"
}
className="flex h-full w-full flex-col items-center justify-center gap-1 p-1 text-center text-[10px] leading-tight text-muted-foreground"
>
<ImagePlusIcon
className="size-4 shrink-0"
aria-hidden="true"
/>
<span className="max-w-full truncate">Image selected</span>
</div>
)}
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`Remove ${attachment.file.name || "image"}`}
onClick={() => removeImageAttachment(attachment.id)}
className="absolute right-1 top-1 rounded-full bg-background/90 text-muted-foreground opacity-90 shadow-sm hover:text-foreground group-hover:opacity-100"
>
<XIcon data-icon="inline-start" aria-hidden="true" />
</Button>
</div>
))}
</div>
) : null}
<PendingUserInputQueue
inputs={visiblePendingUserInputs}
busyActionId={pendingUserInputActionId}
onPreempt={(input) =>
void runPendingUserInputAction(
{ kind: "preempt_pending_user_input", event_id: input.event_id },
pendingUserInputPreemptActionId(input),
)
}
onDismiss={(input) =>
void runPendingUserInputAction(
{ kind: "dismiss_pending_user_input", event_id: input.event_id },
pendingUserInputDismissActionId(input),
)
}
onClear={() =>
void runPendingUserInputAction(
{ kind: "clear_pending_user_inputs" },
pendingUserInputClearActionId(),
)
}
onEdit={(input, incomingText) =>
void runPendingUserInputAction(
{
kind: "update_pending_user_input",
event_id: input.event_id,
incoming_text: incomingText,
},
pendingUserInputEditActionId(input),
)
}
onMoveToPosition={(input, targetPosition) =>
void runPendingUserInputAction(
{
kind: "move_pending_user_input_to_position",
event_id: input.event_id,
target_position: targetPosition,
},
pendingUserInputMoveToPositionActionId(input, targetPosition),
)
}
/>
<InputGroup className="h-auto min-h-11 items-end border-0 bg-transparent shadow-none has-[[data-slot=input-group-control]:focus-visible]:border-transparent has-[[data-slot=input-group-control]:focus-visible]:ring-0 dark:bg-transparent">
<InputGroupTextarea
ref={textareaRef}
value={message}
rows={1}
placeholder={chatPlaceholder}
aria-label="Message"
onChange={(event) => {
setMessage(event.target.value);
setSendError(null);
setSlashCommandSelection(0);
setSlashActionFeedback(null);
updateMessageTextareaHeight();
}}
onPaste={handlePaste}
onKeyDown={(event) => {
if (inputSuggestions.length > 0) {
if (event.key === "ArrowDown") {
event.preventDefault();
setSlashCommandSelection((current) =>
(current + 1) % inputSuggestions.length,
);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setSlashCommandSelection(
(current) =>
(current - 1 + inputSuggestions.length) % inputSuggestions.length,
);
return;
}
if (event.key === "Tab") {
event.preventDefault();
applyInputSuggestion(
selectedInputSuggestion ?? inputSuggestions[0],
);
return;
}
}
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
if (
selectedInputSuggestion &&
selectedInputSuggestion.completion !==
(isWebSlashCommandInput(message) ? message.trim() : message)
) {
applyInputSuggestion(selectedInputSuggestion);
return;
}
event.currentTarget.form?.requestSubmit();
}
}}
className="max-h-[30vh] min-h-11 overflow-y-hidden px-3 py-2.5 text-sm leading-5 placeholder:text-muted-foreground/70"
/>
<InputGroupAddon
align="inline-end"
className="self-end gap-1 pb-1.5 pr-1.5 has-[>button]:mr-0"
>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Attach image"
onClick={() => fileInputRef.current?.click()}
className="rounded-full text-muted-foreground hover:text-foreground"
disabled={
!supportsVision ||
isSending ||
isWebSlashCommandInput(message) ||
imageAttachments.length >= AGENT_CHAT_MAX_IMAGE_ATTACHMENTS
}
>
<ImagePlusIcon data-icon="inline-start" aria-hidden="true" />
</Button>
<Button
type={sendButtonInterruptsRuntime ? "button" : "submit"}
variant={sendButtonInterruptsRuntime ? "destructive" : "default"}
size="icon-sm"
disabled={sendButtonDisabled}
aria-label={
sendButtonInterruptsRuntime
? "Interrupt agent"
: queuedInputLimitBlocksSubmit
? "Queued input limit reached"
: "Send message"
}
title={
sendButtonInterruptsRuntime
? "Interrupt agent"
: queuedInputLimitBlocksSubmit
? queuedInputLimitMessage
: "Send message"
}
onClick={
sendButtonInterruptsRuntime
? () => void interruptRuntimeTurn()
: undefined
}
className="rounded-full"
>
{isSending ? (
<Spinner data-icon="inline-start" />
) : sendButtonInterruptsRuntime ? (
<XIcon data-icon="inline-start" aria-hidden="true" />
) : (
<SendHorizontalIcon data-icon="inline-start" aria-hidden="true" />
)}
</Button>
</InputGroupAddon>
</InputGroup>
{sendError ? (
<Alert variant="destructive" className="mx-2 px-2 py-1">
<AlertDescription className="text-xs">{sendError}</AlertDescription>
</Alert>
) : null}
</form>
);
}
function useDelayedPendingUserInputs(
inputs: DashboardPendingUserInput[],
delayMs: number,
): DashboardPendingUserInput[] {
const [visibleInputTick, setVisibleInputTick] = useState(0);
useEffect(() => {
if (inputs.length === 0) {
return;
}
const now = Date.now();
const nextDelay = inputs.reduce<number | null>((currentDelay, input) => {
const ageMs = Math.max(0, now - input.arrived_at_ms);
const remainingMs = delayMs - ageMs;
if (remainingMs <= 0) {
return currentDelay;
}
return currentDelay === null
? remainingMs
: Math.min(currentDelay, remainingMs);
}, null);
if (nextDelay === null) {
return;
}
const timeoutId = window.setTimeout(() => {
setVisibleInputTick((current) => current + 1);
}, nextDelay);
return () => window.clearTimeout(timeoutId);
}, [delayMs, inputs, visibleInputTick]);
const now = Date.now();
return inputs.filter(
(input) => Math.max(0, now - input.arrived_at_ms) >= delayMs,
);
}
function PendingUserInputQueue({
inputs,
busyActionId,
onPreempt,
onDismiss,
onClear,
onEdit,
onMoveToPosition,
}: {
inputs: DashboardPendingUserInput[];
busyActionId: string | null;
onPreempt: (input: DashboardPendingUserInput) => void;
onDismiss: (input: DashboardPendingUserInput) => void;
onClear: () => void;
onEdit: (input: DashboardPendingUserInput, incomingText: string) => void;
onMoveToPosition: (
input: DashboardPendingUserInput,
targetPosition: number,
) => void;
}) {
const [draggingEventId, setDraggingEventId] = useState<string | null>(null);
const [dragOverEventId, setDragOverEventId] = useState<string | null>(null);
const [editingInput, setEditingInput] =
useState<DashboardPendingUserInput | null>(null);
const [editText, setEditText] = useState("");
const editTextareaId = useId();
const queueBusy = Boolean(busyActionId);
const editBusy = editingInput
? busyActionId === pendingUserInputEditActionId(editingInput)
: false;
useEffect(() => {
if (
editingInput &&
!inputs.some((input) => input.event_id === editingInput.event_id)
) {
setEditingInput(null);
setEditText("");
}
}, [editingInput, inputs]);
if (inputs.length === 0) {
return null;
}
function openEditDialog(input: DashboardPendingUserInput) {
setEditingInput(input);
setEditText(input.incoming_text);
}
function closeEditDialog() {
if (editBusy) {
return;
}
setEditingInput(null);
setEditText("");
}
function handleEditSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
event.stopPropagation();
if (!editingInput || editBusy) {
return;
}
onEdit(editingInput, editText);
setEditingInput(null);
setEditText("");
}
function handleDragStart(
event: DragEvent<HTMLButtonElement>,
input: DashboardPendingUserInput,
) {
if (queueBusy || inputs.length <= 1) {
event.preventDefault();
return;
}
event.stopPropagation();
setDraggingEventId(input.event_id);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", input.event_id);
}
function handleDragOver(
event: DragEvent<HTMLDivElement>,
input: DashboardPendingUserInput,
) {
if (!draggingEventId || draggingEventId === input.event_id) {
return;
}
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "move";
setDragOverEventId(input.event_id);
}
function handleDrop(
event: DragEvent<HTMLDivElement>,
targetInput: DashboardPendingUserInput,
) {
const sourceEventId =
draggingEventId || event.dataTransfer.getData("text/plain");
setDraggingEventId(null);
setDragOverEventId(null);
if (!sourceEventId || sourceEventId === targetInput.event_id) {
return;
}
event.preventDefault();
event.stopPropagation();
const sourceInput = inputs.find((input) => input.event_id === sourceEventId);
const targetPosition = inputs.findIndex(
(input) => input.event_id === targetInput.event_id,
);
if (!sourceInput || targetPosition < 0) {
return;
}
onMoveToPosition(sourceInput, targetPosition);
}
function resetDragState() {
setDraggingEventId(null);
setDragOverEventId(null);
}
return (
<>
<section
role="region"
aria-label="Pending user inputs"
aria-busy={queueBusy || undefined}
className="mb-2 border-b border-border/60 pb-2"
>
<ScrollArea className="max-h-40" viewportClassName="max-h-40">
<div role="list">
{inputs.map((input, index) => {
const position = index + 1;
const dismissBusy =
busyActionId === pendingUserInputDismissActionId(input);
const preemptBusy =
busyActionId === pendingUserInputPreemptActionId(input);
const moveBusy = Boolean(
busyActionId?.startsWith(`${input.event_id}:move-to:`),
);
const preview = pendingUserInputPreview(input);
const canReorder = inputs.length > 1;
const dragOver =
dragOverEventId === input.event_id &&
draggingEventId !== input.event_id;
return (
<div
key={input.event_id}
role="listitem"
onDragOver={(event) => handleDragOver(event, input)}
onDrop={(event) => handleDrop(event, input)}
onDragEnd={resetDragState}
className={cn(
"grid min-w-0 grid-cols-[1.5rem_minmax(0,1fr)_auto_auto] items-start gap-x-2 px-2 py-1 text-sm leading-5 text-foreground/90",
dragOver && "rounded-md bg-accent/60",
)}
>
<Button
type="button"
variant="ghost"
size="icon-xs"
draggable={canReorder && !queueBusy}
aria-label={`Drag pending input ${position}`}
title={canReorder ? "Drag to reorder" : "Only one queued input"}
disabled={queueBusy || !canReorder}
onDragStart={(event) => handleDragStart(event, input)}
onDragEnd={resetDragState}
className="rounded-full text-muted-foreground hover:text-foreground disabled:opacity-60 enabled:cursor-grab enabled:active:cursor-grabbing"
>
{moveBusy ? (
<Spinner data-icon="inline-start" />
) : (
<GripVerticalIcon data-icon="inline-start" aria-hidden="true" />
)}
</Button>
<p className="min-w-0 truncate" title={preview}>
{preview}
</p>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`Run pending input ${position} now`}
title="Run this queued input now"
disabled={queueBusy}
onClick={() => onPreempt(input)}
className="rounded-full text-muted-foreground hover:text-foreground"
>
{preemptBusy ? (
<Spinner data-icon="inline-start" />
) : (
<CornerDownLeftIcon data-icon="inline-start" aria-hidden="true" />
)}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`More actions for pending input ${position}`}
title="More actions"
disabled={queueBusy}
className="rounded-full text-muted-foreground hover:text-foreground"
>
{dismissBusy ? (
<Spinner data-icon="inline-start" />
) : (
<MoreHorizontalIcon
data-icon="inline-start"
aria-hidden="true"
/>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuGroup>
<DropdownMenuItem
disabled={queueBusy}
onSelect={() => openEditDialog(input)}
>
<PencilIcon />
<span>Edit message</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={queueBusy}
variant="destructive"
onSelect={() => onDismiss(input)}
>
<Trash2Icon />
<span>Dismiss message</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={queueBusy}
variant="destructive"
onSelect={onClear}
>
<Trash2Icon />
<span>Clear queue</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
</ScrollArea>
</section>
<Dialog
open={editingInput !== null}
onOpenChange={(open) => {
if (!open) {
closeEditDialog();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit queued message</DialogTitle>
<DialogDescription>
Update this pending input before the agent handles it.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleEditSubmit} className="flex flex-col gap-4">
<FieldGroup className="gap-3">
<Field>
<FieldLabel htmlFor={editTextareaId}>Message</FieldLabel>
<Textarea
id={editTextareaId}
value={editText}
rows={5}
disabled={editBusy}
onChange={(event) => setEditText(event.target.value)}
/>
</Field>
</FieldGroup>
{editingInput && editingInput.attachment_count > 0 ? (
<p className="text-xs text-muted-foreground">
Attachments stay queued with this message.
</p>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={editBusy}
onClick={closeEditDialog}
>
Cancel
</Button>
<Button type="submit" disabled={editBusy}>
{editBusy ? <Spinner data-icon="inline-start" /> : null}
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}
function pendingUserInputMoveToPositionActionId(
input: DashboardPendingUserInput,
targetPosition: number,
) {
return `${input.event_id}:move-to:${targetPosition}`;
}
function pendingUserInputEditActionId(input: DashboardPendingUserInput) {
return `${input.event_id}:edit`;
}
function pendingUserInputClearActionId() {
return "pending-user-inputs:clear";
}
function pendingUserInputPreemptActionId(input: DashboardPendingUserInput) {
return `${input.event_id}:preempt`;
}
function pendingUserInputDismissActionId(input: DashboardPendingUserInput) {
return `${input.event_id}:dismiss`;
}
function pendingUserInputPreview(input: DashboardPendingUserInput) {
const text = input.incoming_text.trim();
if (text) {
return text;
}
return input.attachment_count > 0 ? "Attachment-only input" : "Empty input";
}
function WebSlashCommandPanel({
panel,
snapshot,
feedback,
actionFeedback,
suggestions,
selectedSuggestionIndex,
isSending,
onClosePanel,
onSetPanel,
onCloseActionFeedback,
onSelectSuggestion,
onHoverSuggestion,
onRunAction,
onRunDashboardAction,
}: {
panel: WebSlashPanel | null;
snapshot: DashboardSnapshot | null;
feedback: WebSlashCommandFeedback | null;
actionFeedback: WebSlashActionFeedback | null;
suggestions: WebInputSuggestion[];
selectedSuggestionIndex: number;
isSending: boolean;
onClosePanel: () => void;
onSetPanel: (panel: WebSlashPanel | null) => void;
onCloseActionFeedback: () => void;
onSelectSuggestion: (suggestion: WebInputSuggestion) => void;
onHoverSuggestion: (index: number) => void;
onRunAction: (command: string, detailTitle?: string) => void;
onRunDashboardAction: (
action: DashboardAction,
) => Promise<DashboardActionResult | null>;
}) {
const hasContent =
Boolean(panel) ||
Boolean(actionFeedback) ||
Boolean(feedback) ||
suggestions.length > 0;
if (!hasContent) {
return null;
}
return (
<div className="mb-2 flex flex-col gap-2 border-b border-border/70 px-2 pb-2">
{panel ? (
<WebSlashPanelView
panel={panel}
snapshot={snapshot}
isSending={isSending}
onClose={onClosePanel}
onSetPanel={onSetPanel}
onRunAction={onRunAction}
onRunDashboardAction={onRunDashboardAction}
/>
) : null}
{actionFeedback ? (
<WebSlashCommandFeedbackView
feedback={actionFeedback}
onClose={actionFeedback.dismissible ? onCloseActionFeedback : undefined}
/>
) : null}
{!panel && feedback ? (
<WebSlashCommandFeedbackView feedback={feedback} />
) : null}
{!panel && suggestions.length > 0 ? (
<section
aria-label="Command suggestions"
className="flex flex-col gap-1"
>
{suggestions.slice(0, 6).map((suggestion, index) => {
const selected = index === selectedSuggestionIndex;
return (
<Button
key={`${suggestion.completion}-${index}`}
type="button"
variant="ghost"
onMouseEnter={() => onHoverSuggestion(index)}
onMouseDown={(event) => {
event.preventDefault();
onSelectSuggestion(suggestion);
}}
className={cn(
"h-auto w-full min-w-0 justify-start gap-3 px-2 py-1.5 text-left text-sm",
selected
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground",
)}
>
<span className="shrink-0 font-mono text-xs">
{suggestion.display}
</span>
<span className="min-w-0 truncate text-xs text-muted-foreground/75">
{suggestion.description}
</span>
</Button>
);
})}
</section>
) : null}
</div>
);
}
function WebSlashPanelView({
panel,
snapshot,
isSending,
onClose,
onSetPanel,
onRunAction,
onRunDashboardAction,
}: {
panel: WebSlashPanel;
snapshot: DashboardSnapshot | null;
isSending: boolean;
onClose: () => void;
onSetPanel: (panel: WebSlashPanel | null) => void;
onRunAction: (command: string, detailTitle?: string) => void;
onRunDashboardAction: (
action: DashboardAction,
) => Promise<DashboardActionResult | null>;
}) {
switch (panel.kind) {
case "selection":
return (
<WebSlashSelectionPanel
panel={panel.panel}
snapshot={snapshot}
isSending={isSending}
onClose={onClose}
onSetPanel={onSetPanel}
onRunAction={onRunAction}
onRunDashboardAction={onRunDashboardAction}
/>
);
case "status":
return <WebSlashStatusPanel snapshot={snapshot} onClose={onClose} />;
case "sleep-status":
return <WebSlashSleepStatusPanel snapshot={snapshot} onClose={onClose} />;
case "detail":
return (
<WebSlashDetailPanel
title={panel.title}
text={panel.text}
onClose={onClose}
/>
);
case "skills-list":
return (
<WebSlashSkillsListPanel
panel={panel}
snapshot={snapshot}
onClose={onClose}
onSetPanel={onSetPanel}
/>
);
case "skills-toggle":
return (
<WebSlashSkillsTogglePanel
panel={panel}
snapshot={snapshot}
isSending={isSending}
onClose={onClose}
onSetPanel={onSetPanel}
onRunDashboardAction={onRunDashboardAction}
/>
);
case "workflow-form":
return (
<WebSlashWorkflowFormPanel
panel={panel}
snapshot={snapshot}
isSending={isSending}
onClose={onClose}
onSetPanel={onSetPanel}
onRunDashboardAction={onRunDashboardAction}
/>
);
}
}
function WebSlashPanelShell({
title,
subtitle,
children,
onClose,
}: {
title: string;
subtitle?: string;
children: ReactNode;
onClose: () => void;
}) {
return (
<section aria-label={title} className="flex min-h-0 flex-col gap-2 text-sm">
<div className="flex min-w-0 items-start gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{title}</p>
{subtitle ? (
<p className="truncate text-xs text-muted-foreground">{subtitle}</p>
) : null}
</div>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`Close ${title}`}
onClick={onClose}
className="shrink-0 rounded-full text-muted-foreground hover:text-foreground"
>
<XIcon data-icon="inline-start" aria-hidden="true" />
</Button>
</div>
<ScrollArea
className="-mx-2 min-h-0 max-h-72"
viewportClassName="max-h-72 px-2"
>
<div className="flex min-w-0 flex-col gap-2">{children}</div>
</ScrollArea>
</section>
);
}
function WebSlashWorkflowFormPanel({
panel,
snapshot,
isSending,
onClose,
onSetPanel,
onRunDashboardAction,
}: {
panel: Extract<WebSlashPanel, { kind: "workflow-form" }>;
snapshot: DashboardSnapshot | null;
isSending: boolean;
onClose: () => void;
onSetPanel: (panel: WebSlashPanel | null) => void;
onRunDashboardAction: (
action: DashboardAction,
) => Promise<DashboardActionResult | null>;
}) {
const workflow = webSlashWorkflowById(snapshot, panel.workflowId);
if (!workflow) {
return (
<WebSlashPanelShell
title="Workflow"
subtitle="The selected workflow is no longer loaded."
onClose={onClose}
>
<Alert>
<InfoIcon className="size-4" aria-hidden="true" />
<AlertDescription className="text-xs">
Return to /workflows and choose a currently loaded workflow.
</AlertDescription>
</Alert>
</WebSlashPanelShell>
);
}
const selectedWorkflow = workflow;
function updateField(name: string, value: string) {
onSetPanel({
kind: "workflow-form",
workflowId: selectedWorkflow.id,
values: { ...panel.values, [name]: value },
feedback: null,
});
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
let input: Record<string, unknown>;
try {
input = webSlashWorkflowInputFromValues(selectedWorkflow, panel.values);
} catch (error) {
onSetPanel({
...panel,
feedback: {
title: `WORKFLOW ${selectedWorkflow.id}`,
message: error instanceof Error ? error.message : String(error),
level: "error",
},
});
return;
}
const result = await onRunDashboardAction({
kind: "run_workflow",
workflow_id: selectedWorkflow.id,
input,
});
if (result) {
onSetPanel({
...panel,
feedback: webSlashActionFeedbackForResult(
`/workflows ${selectedWorkflow.id}`,
result,
),
});
}
}
const fieldCount = selectedWorkflow.input_fields.length;
return (
<WebSlashPanelShell
title={`Workflow ${selectedWorkflow.id}`}
subtitle={
fieldCount === 0
? "No declared input fields. Run this workflow directly."
: `${fieldCount} typed input field${fieldCount === 1 ? "" : "s"}.`
}
onClose={onClose}
>
<form className="flex min-w-0 flex-col gap-3" onSubmit={submit}>
{panel.feedback ? (
<WebSlashCommandFeedbackView feedback={panel.feedback} />
) : null}
{fieldCount > 0 ? (
<FieldGroup>
{selectedWorkflow.input_fields.map((field) => {
const type = webSlashWorkflowSchemaTypeLabel(field.schema);
const nullable = webSlashWorkflowSchemaIsNullable(field.schema);
const multiline = webSlashWorkflowSchemaUsesJsonInput(field.schema);
const value =
panel.values[field.name] ??
webSlashWorkflowDefaultValueText(field.schema);
const fieldId = `workflow-${selectedWorkflow.id}-${field.name}`;
const hint = nullable
? "Leave empty or enter null for a null value."
: multiline
? "Enter a JSON value."
: undefined;
return (
<Field key={field.name} className="gap-1.5">
<FieldLabel htmlFor={fieldId} className="flex min-w-0 gap-2">
<span className="min-w-0 truncate">{field.name}</span>
<span className="shrink-0 font-mono text-xs font-normal text-muted-foreground">
{type}
</span>
</FieldLabel>
{multiline ? (
<Textarea
id={fieldId}
value={value}
disabled={isSending}
placeholder={webSlashWorkflowFieldPlaceholder(field.schema)}
onChange={(event) => updateField(field.name, event.target.value)}
className="min-h-20 font-mono text-xs"
/>
) : (
<Input
id={fieldId}
value={value}
disabled={isSending}
placeholder={webSlashWorkflowFieldPlaceholder(field.schema)}
onChange={(event) => updateField(field.name, event.target.value)}
className="h-8 font-mono text-xs"
/>
)}
{hint ? (
<p className="text-xs leading-4 text-muted-foreground">{hint}</p>
) : null}
</Field>
);
})}
</FieldGroup>
) : null}
<div className="flex items-center justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={isSending}>
Cancel
</Button>
<Button type="submit" disabled={isSending}>
{isSending ? <Spinner className="size-3.5" /> : null}
{isSending ? "Queuing workflow" : "Run workflow"}
</Button>
</div>
</form>
</WebSlashPanelShell>
);
}
function webSlashWorkflowById(snapshot: DashboardSnapshot | null, workflowId: string) {
return (snapshot?.workflows ?? []).find((workflow) => workflow.id === workflowId) ?? null;
}
function webSlashWorkflowSchemaRecord(schema: unknown): Record<string, unknown> | null {
return schema && typeof schema === "object" && !Array.isArray(schema)
? (schema as Record<string, unknown>)
: null;
}
function webSlashWorkflowSchemaType(schema: unknown) {
const type = webSlashWorkflowSchemaRecord(schema)?.type;
if (typeof type === "string") {
return type;
}
if (Array.isArray(type)) {
return (
type.find(
(candidate): candidate is string =>
typeof candidate === "string" && candidate !== "null",
) ?? null
);
}
return null;
}
function webSlashWorkflowSchemaIsNullable(schema: unknown) {
const type = webSlashWorkflowSchemaRecord(schema)?.type;
return Array.isArray(type) && type.includes("null");
}
function webSlashWorkflowSchemaTypeLabel(schema: unknown) {
const type = webSlashWorkflowSchemaType(schema) ?? "string";
return webSlashWorkflowSchemaIsNullable(schema) ? `${type} | null` : type;
}
function webSlashWorkflowSchemaUsesJsonInput(schema: unknown) {
const type = webSlashWorkflowSchemaType(schema);
return type === "array" || type === "object";
}
function webSlashWorkflowDefaultValueText(schema: unknown) {
const schemaRecord = webSlashWorkflowSchemaRecord(schema);
if (schemaRecord && "default" in schemaRecord) {
const value = schemaRecord.default;
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value) ?? "";
} catch {
return "";
}
}
switch (webSlashWorkflowSchemaType(schema)) {
case "integer":
case "number":
return "0";
case "boolean":
return "false";
case "array":
return "[]";
case "object":
return "{}";
default:
return "";
}
}
function webSlashWorkflowFieldPlaceholder(schema: unknown) {
const nullable = webSlashWorkflowSchemaIsNullable(schema);
const type = webSlashWorkflowSchemaType(schema);
const base =
type === "boolean"
? "true or false"
: type === "array"
? "[]"
: type === "object"
? "{}"
: type === "integer" || type === "number"
? "0"
: "text";
return nullable ? `${base} or null` : base;
}
function webSlashWorkflowInputFromValues(
workflow: NonNullable<DashboardSnapshot["workflows"]>[number],
values: Record<string, string>,
) {
const input: Record<string, unknown> = {};
for (const field of workflow.input_fields) {
input[field.name] = webSlashWorkflowParseFieldValue(
field.name,
values[field.name] ?? webSlashWorkflowDefaultValueText(field.schema),
field.schema,
);
}
return input;
}
function webSlashWorkflowParseFieldValue(
fieldName: string,
value: string,
schema: unknown,
): unknown {
const trimmed = value.trim();
if (
webSlashWorkflowSchemaIsNullable(schema) &&
(!trimmed || trimmed.toLowerCase() === "null")
) {
return null;
}
switch (webSlashWorkflowSchemaType(schema)) {
case "string":
return value;
case "integer": {
if (!/^[+-]?\d+$/.test(trimmed)) {
throw new Error(`${fieldName} must be an integer.`);
}
const parsed = Number(trimmed);
if (!Number.isSafeInteger(parsed)) {
throw new Error(`${fieldName} must be a safe integer.`);
}
return parsed;
}
case "number": {
const parsed = Number(trimmed);
if (!Number.isFinite(parsed)) {
throw new Error(`${fieldName} must be a number.`);
}
return parsed;
}
case "boolean":
if (trimmed === "true") {
return true;
}
if (trimmed === "false") {
return false;
}
throw new Error(`${fieldName} must be true or false.`);
case "array":
case "object": {
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
throw new Error(`${fieldName} must be valid JSON.`);
}
if (webSlashWorkflowSchemaType(schema) === "array" && !Array.isArray(parsed)) {
throw new Error(`${fieldName} must be a JSON array.`);
}
if (
webSlashWorkflowSchemaType(schema) === "object" &&
(!parsed || typeof parsed !== "object" || Array.isArray(parsed))
) {
throw new Error(`${fieldName} must be a JSON object.`);
}
return parsed;
}
default:
return value;
}
}
function WebSlashSelectionPanel({
panel,
snapshot,
isSending,
onClose,
onSetPanel,
onRunAction,
onRunDashboardAction,
}: {
panel: Extract<WebSlashPanel, { kind: "selection" }>["panel"];
snapshot: DashboardSnapshot | null;
isSending: boolean;
onClose: () => void;
onSetPanel: (panel: WebSlashPanel | null) => void;
onRunAction: (command: string, detailTitle?: string) => void;
onRunDashboardAction: (
action: DashboardAction,
) => Promise<DashboardActionResult | null>;
}) {
const meta = webSlashSelectionMeta(panel, snapshot);
const items = webSlashSelectionItems(panel, snapshot);
return (
<WebSlashPanelShell
title={meta.title}
subtitle={meta.subtitle}
onClose={onClose}
>
<div className="flex min-w-0 flex-col gap-1">
{items.length > 0 ? (
items.map((item, index) => (
<Button
key={item.id}
type="button"
variant="ghost"
disabled={isSending || item.disabled}
onClick={() =>
webSlashRunSelectionItem(
item.id,
snapshot,
onSetPanel,
onRunAction,
onRunDashboardAction,
)
}
className={cn(
"group h-auto w-full min-w-0 justify-start gap-2 px-2 py-1.5 text-left text-sm disabled:cursor-not-allowed disabled:opacity-45",
index === 0 && !item.disabled && "bg-muted text-foreground",
)}
>
<span
aria-hidden="true"
className={cn(
"w-3 shrink-0 text-primary opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100",
index === 0 && !item.disabled && "opacity-100",
)}
>
›
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-foreground">
{item.name}
</span>
<span className="block truncate text-xs text-muted-foreground">
{item.description}
</span>
</span>
</Button>
))
) : (
<p className="px-2 py-3 text-sm text-muted-foreground">No items.</p>
)}
</div>
</WebSlashPanelShell>
);
}
function WebSlashStatusPanel({
snapshot,
onClose,
}: {
snapshot: DashboardSnapshot | null;
onClose: () => void;
}) {
const status = webSlashStatusSnapshot(snapshot);
const tokenUsageSections = webSlashStatusTokenUsageSections(snapshot);
return (
<WebSlashPanelShell
title="STATUS"
subtitle="Current session runtime facts."
onClose={onClose}
>
<div className="flex min-w-0 flex-col gap-3">
{!snapshot ? (
<Alert className="px-2 py-1">
<InfoIcon className="size-4" aria-hidden="true" />
<AlertDescription className="text-xs">
Status snapshot is still loading; showing placeholder values.
</AlertDescription>
</Alert>
) : null}
<section className="flex flex-col gap-2" aria-label="Model usage">
<p className="text-sm font-medium text-foreground">Model usage</p>
<div className="flex flex-col gap-3">
{tokenUsageSections.length > 0 ? (
tokenUsageSections.map((section, index) => (
<Fragment key={section.role}>
<WebSlashStatusTokenUsageSection section={section} />
{index < tokenUsageSections.length - 1 ? <Separator /> : null}
</Fragment>
))
) : (
<Empty className="py-3">
<EmptyHeader>
<EmptyTitle>No token usage recorded yet.</EmptyTitle>
</EmptyHeader>
</Empty>
)}
</div>
</section>
<Separator />
<section className="flex flex-col gap-2" aria-label="Plan">
<p className="text-sm font-medium text-foreground">Plan</p>
{status.planSteps.length > 0 ? (
<ul className="flex flex-col gap-1">
{status.planSteps.map((step, index) => (
<li
key={`${index}-${step.step}`}
className="flex min-w-0 items-start gap-2 text-sm"
>
<span className="shrink-0 text-muted-foreground" aria-hidden="true">
•
</span>
<span className="min-w-0 flex-1 break-words text-foreground">
{step.step}
</span>
<Badge
variant={webSlashPlanStatusBadgeVariant(step.status)}
className="shrink-0"
>
{step.status}
</Badge>
</li>
))}
</ul>
) : (
<Empty className="py-3">
<EmptyHeader>
<EmptyTitle>No active plan items.</EmptyTitle>
</EmptyHeader>
</Empty>
)}
</section>
</div>
</WebSlashPanelShell>
);
}
type WebSlashBadgeVariant =
| "default"
| "secondary"
| "destructive"
| "outline"
| "ghost";
type WebSlashStatusSnapshotView = {
planSteps: DashboardPlanStep[];
};
type WebSlashStatusTokenUsageSectionData = {
role: string;
model: string;
context: {
percent: number;
text: string;
} | null;
lastTurnParts: string[];
totalText: string;
cachedText: string | null;
};
function WebSlashStatusTokenUsageSection({
section,
}: {
section: WebSlashStatusTokenUsageSectionData;
}) {
return (
<section className="flex flex-col gap-2" aria-label={`${section.role} model usage`}>
<div className="flex min-w-0 items-center gap-2">
<Badge variant="outline" className="shrink-0">
{section.role}
</Badge>
<span className="min-w-0 truncate text-sm font-medium text-foreground">
{section.model}
</span>
</div>
{section.context ? (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>Context</span>
<span>{section.context.text}</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary"
style={{
width: `${Math.min(Math.max(section.context.percent, 0), 1) * 100}%`,
}}
/>
</div>
</div>
) : null}
{section.lastTurnParts.length > 0 ? (
<p className="text-xs text-muted-foreground">
Last turn: {section.lastTurnParts.join(" · ")}
</p>
) : null}
<p className="text-xs text-muted-foreground">
Total: {section.totalText}
{section.cachedText ? ` · ${section.cachedText}` : ""}
</p>
</section>
);
}
function WebSlashSleepStatusPanel({
snapshot,
onClose,
}: {
snapshot: DashboardSnapshot | null;
onClose: () => void;
}) {
return (
<WebSlashPanelShell
title="SLEEP STATUS"
subtitle="Background optimization state."
onClose={onClose}
>
<div className="flex min-w-0 flex-col gap-3">
<div className="flex flex-col gap-1">
<p className="text-xs font-medium text-foreground">
Runtime optimization
</p>
<WebSlashKeyValueRows rows={webSlashRuntimeOptimizationRows(snapshot)} />
</div>
<div className="flex flex-col gap-1">
<p className="text-xs font-medium text-foreground">
Primitive optimization
</p>
<WebSlashKeyValueRows rows={webSlashPrimitiveOptimizationRows(snapshot)} />
</div>
</div>
</WebSlashPanelShell>
);
}
function WebSlashDetailPanel({
title,
text,
onClose,
}: {
title: string;
text: string;
onClose: () => void;
}) {
return (
<WebSlashPanelShell title={title} onClose={onClose}>
<div className="min-w-0 rounded-md bg-muted/35 p-2 font-mono text-xs leading-5 text-foreground/90">
{renderWebSlashDetailLines(text).map((line, index) => (
<div
key={`${index}-${line.text}`}
className={cn(
"min-h-5 whitespace-pre-wrap break-words",
line.kind === "header" && "font-semibold text-foreground",
line.kind === "bullet" && "pl-2 text-muted-foreground",
line.kind === "label" && "text-muted-foreground",
)}
>
{line.text}
</div>
))}
</div>
</WebSlashPanelShell>
);
}
function WebSlashSkillsListPanel({
panel,
snapshot,
onClose,
onSetPanel,
}: {
panel: Extract<WebSlashPanel, { kind: "skills-list" }>;
snapshot: DashboardSnapshot | null;
onClose: () => void;
onSetPanel: (panel: WebSlashPanel | null) => void;
}) {
const skills = webSlashFilteredSkills(snapshot, panel.search);
const errors = snapshot?.skill_errors ?? [];
return (
<WebSlashPanelShell
title="Skills"
subtitle={
(snapshot?.skills ?? []).length > 0
? `${snapshot?.skills?.length ?? 0} loaded. Choose a skill to inspect.`
: "No skills loaded."
}
onClose={onClose}
>
<Input
value={panel.search}
onChange={(event) =>
onSetPanel({ kind: "skills-list", search: event.target.value })
}
placeholder="Type to search skills"
className="h-8"
/>
{errors.length > 0 ? (
<div className="flex flex-col gap-1 text-xs">
{errors.slice(0, 2).map((error) => (
<div
key={`${error.path}-${error.message}`}
className="flex min-w-0 gap-2 text-muted-foreground"
>
<span className="shrink-0 text-primary">!</span>
<span className="min-w-0 truncate">{error.path}</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground/75">
{error.message}
</span>
</div>
))}
</div>
) : null}
<div className="flex min-w-0 flex-col gap-1">
{skills.length > 0 ? (
skills.map((skill, index) => (
<Button
key={skill.path}
type="button"
variant="ghost"
onClick={() =>
onSetPanel({
kind: "detail",
title: `SKILL ${skill.name}`,
text: webSlashSkillDetailText(skill),
})
}
className={cn(
"group h-auto w-full min-w-0 justify-start gap-2 px-2 py-1.5 text-left text-sm",
index === 0 && "bg-muted text-foreground",
)}
>
<span
aria-hidden="true"
className={cn(
"w-3 shrink-0 text-primary opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100",
index === 0 && "opacity-100",
)}
>
›
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-foreground">
{skill.name}
</span>
<span className="block truncate text-xs text-muted-foreground">
{webSlashSkillStatusDescription(skill)} {skill.description}
</span>
</span>
</Button>
))
) : (
<p className="px-2 py-3 text-sm text-muted-foreground">
{panel.search.trim() ? "No matches." : "No skills loaded."}
</p>
)}
</div>
</WebSlashPanelShell>
);
}
function WebSlashSkillsTogglePanel({
panel,
snapshot,
isSending,
onClose,
onSetPanel,
onRunDashboardAction,
}: {
panel: Extract<WebSlashPanel, { kind: "skills-toggle" }>;
snapshot: DashboardSnapshot | null;
isSending: boolean;
onClose: () => void;
onSetPanel: (panel: WebSlashPanel | null) => void;
onRunDashboardAction: (action: DashboardAction) => void;
}) {
const skills = webSlashFilteredSkills(snapshot, panel.search);
return (
<WebSlashPanelShell
title="Skills"
subtitle={
(snapshot?.skills ?? []).length > 0
? "Toggle automatic use for loaded skills."
: "No skills loaded."
}
onClose={onClose}
>
<Input
value={panel.search}
onChange={(event) =>
onSetPanel({
kind: "skills-toggle",
search: event.target.value,
feedback: panel.feedback ?? null,
})
}
placeholder="Type to search skills"
className="h-8"
/>
{panel.feedback ? (
<WebSlashCommandFeedbackView feedback={panel.feedback} />
) : null}
<div className="flex min-w-0 flex-col gap-1">
{skills.length > 0 ? (
skills.map((skill, index) => (
<Button
key={skill.path}
type="button"
variant="ghost"
disabled={isSending}
onClick={() =>
onRunDashboardAction({
kind: "set_skill_auto_use",
path: skill.path,
enabled: !skill.auto_use_enabled,
})
}
className={cn(
"group h-auto w-full min-w-0 justify-start gap-2 px-2 py-1.5 text-left text-sm disabled:cursor-not-allowed disabled:opacity-60",
index === 0 && "bg-muted text-foreground",
)}
>
<span
aria-hidden="true"
className={cn(
"w-3 shrink-0 text-primary opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100",
index === 0 && "opacity-100",
)}
>
›
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{skill.auto_use_enabled ? "[x]" : "[ ]"}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-foreground">
{skill.name}
</span>
<span className="block truncate text-xs text-muted-foreground">
{skill.scope} - {webSlashSkillStatusDescription(skill)}
</span>
</span>
</Button>
))
) : (
<p className="px-2 py-3 text-sm text-muted-foreground">
{panel.search.trim() ? "No matches." : "No skills loaded."}
</p>
)}
</div>
</WebSlashPanelShell>
);
}
function WebSlashKeyValueRows({
rows,
}: {
rows: Array<[string, string | number | null | undefined]>;
}) {
return (
<div className="grid min-w-0 grid-cols-[minmax(7rem,auto)_1fr] gap-x-4 gap-y-1 text-sm">
{rows.map(([label, value]) => (
<Fragment key={label}>
<span className="truncate font-medium text-foreground">{label}</span>
<span className="min-w-0 truncate text-muted-foreground">
{formatWebSlashValue(value)}
</span>
</Fragment>
))}
</div>
);
}
function WebSlashCommandFeedbackView({
feedback,
onClose,
}: {
feedback: WebSlashCommandFeedback;
onClose?: () => void;
}) {
return (
<div className="flex min-w-0 items-start gap-2 text-sm">
<WebSlashCommandLevelIcon level={feedback.level} />
<div className="min-w-0 flex-1">
<p className="break-words text-sm font-medium leading-5 text-foreground">
{feedback.message}
</p>
{feedback.detail ? (
<p className="break-words text-xs leading-5 text-muted-foreground">
{feedback.detail}
</p>
) : null}
</div>
{onClose ? (
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Dismiss command feedback"
onClick={onClose}
className="shrink-0 rounded-full text-muted-foreground hover:text-foreground"
>
<XIcon data-icon="inline-start" aria-hidden="true" />
</Button>
) : null}
</div>
);
}
function WebSlashCommandLevelIcon({ level }: { level: WebSlashCommandLevel }) {
if (level === "error") {
return (
<AlertTriangleIcon
className="mt-0.5 size-4 shrink-0 text-destructive"
aria-hidden="true"
/>
);
}
if (level === "warning") {
return (
<InfoIcon
className="mt-0.5 size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
);
}
return (
<CommandIcon
className="mt-0.5 size-4 shrink-0 text-primary"
aria-hidden="true"
/>
);
}
function hasImageDragItems(dataTransfer: DataTransfer) {
return Array.from(dataTransfer.items).some((item) => {
if (item.kind === "file") {
return item.type.startsWith("image/");
}
return false;
});
}
function shouldRenderInlineImagePreview(file: File) {
return (
file.size <= AGENT_CHAT_INLINE_PREVIEW_MAX_BYTES &&
typeof URL !== "undefined" &&
typeof URL.createObjectURL === "function"
);
}
function createImagePreviewUrl(file: File) {
if (!shouldRenderInlineImagePreview(file)) {
return undefined;
}
try {
return URL.createObjectURL(file);
} catch {
return undefined;
}
}
function revokeImagePreviewUrl(attachment: AgentChatPendingImageAttachment) {
if (
!attachment.previewUrl ||
typeof URL === "undefined" ||
typeof URL.revokeObjectURL !== "function"
) {
return;
}
try {
URL.revokeObjectURL(attachment.previewUrl);
} catch {
// Some mobile WebViews throw while revoking blob URLs during teardown.
}
}
function isWebSlashCommandInput(input: string) {
return input.trimStart().startsWith("/");
}
function webSlashCommandBody(input: string) {
const trimmedStart = input.trimStart();
if (!trimmedStart.startsWith("/")) {
return null;
}
return trimmedStart.slice(1);
}
function parseWebSlashCommand(input: string) {
const body = webSlashCommandBody(input);
if (body === null) {
return null;
}
const trimmed = body.trim();
return {
body,
trimmed,
trailingSpace: body.endsWith(" "),
parts: trimmed ? trimmed.split(/\s+/) : [],
};
}
function webInputSuggestions(
input: string,
snapshot: DashboardSnapshot | null,
): WebInputSuggestion[] {
if (isWebSlashCommandInput(input)) {
return webSlashCommandSuggestions(input, snapshot);
}
return webSkillMentionSuggestions(input, snapshot);
}
function webSkillMentionSuggestions(
input: string,
snapshot: DashboardSnapshot | null,
): WebInputSuggestion[] {
const target = webSkillCompletionTarget(input);
if (!target) {
return [];
}
return (snapshot?.skills ?? [])
.filter((skill) => skill.name.startsWith(target.prefix))
.filter((skill) => webSkillNameIsUnique(snapshot, skill.name))
.map((skill) => ({
display: `$${skill.name}`,
completion: `${input.slice(0, target.mentionStart)}$${skill.name}`,
description: webSkillSuggestionDescription(skill),
}));
}
function webSkillCompletionTarget(input: string) {
const mentionStart = input.lastIndexOf("$");
if (mentionStart < 0) {
return null;
}
const prefix = input.slice(mentionStart + 1);
if (!/^[A-Za-z0-9_:-]*$/.test(prefix)) {
return null;
}
return { mentionStart, prefix };
}
function webSkillNameIsUnique(
snapshot: DashboardSnapshot | null,
name: string,
) {
return (snapshot?.skills ?? []).filter((skill) => skill.name === name).length === 1;
}
function webSkillSuggestionDescription(
skill: NonNullable<DashboardSnapshot["skills"]>[number],
) {
const status = webSlashSkillStatusDescription(skill);
return skill.description ? `${skill.description} — ${status}` : status;
}
function webSlashCommandSuggestions(
input: string,
_snapshot: DashboardSnapshot | null,
): WebInputSuggestion[] {
const parsed = parseWebSlashCommand(input);
if (!parsed) {
return [];
}
if (!parsed.trimmed) {
return WEB_SLASH_COMMANDS.map(webSlashRootSuggestion);
}
const [verb] = parsed.parts;
if (parsed.parts.length > 1 || parsed.body.endsWith(" ")) {
return [];
}
return WEB_SLASH_COMMANDS.filter((candidate) =>
candidate.name.startsWith(verb),
).map(webSlashRootSuggestion);
}
function webSlashCommandFeedback(
input: string,
snapshot: DashboardSnapshot | null,
attachmentCount: number,
): WebSlashCommandFeedback | null {
const parsed = parseWebSlashCommand(input);
if (!parsed || !parsed.trimmed) {
return null;
}
if (attachmentCount > 0) {
return {
title: "COMMAND",
message: "Commands cannot include image attachments.",
detail: "Remove the image or send it as a normal agent message.",
level: "error",
blocksSubmit: true,
};
}
const [verb] = parsed.parts;
const command = webSlashFindCommand(verb);
if (!command) {
if (webSlashCommandSuggestions(input, snapshot).length > 0) {
return null;
}
return {
title: "UNKNOWN COMMAND",
message: `No dashboard command named '${verb}'.`,
detail: "Type / to browse available commands.",
level: "error",
blocksSubmit: true,
};
}
const extraArgumentFeedback = webSlashExtraArgumentFeedback(parsed.parts);
if (extraArgumentFeedback) {
return extraArgumentFeedback;
}
if (verb === "debug" && parsed.parts.length > 1) {
const view = parsed.parts[1];
if (!["persona", "system-prompt", "system_prompt", "context", "preturn-context", "preturn_context"].includes(view)) {
return {
title: "DEBUG",
message: `Unknown debug view '${view}'.`,
detail: "Use /debug to choose a view.",
level: "error",
blocksSubmit: true,
};
}
}
if (
webSlashCommandAccepts("app-status", verb) &&
parsed.parts.length === 2 &&
!webSlashAppNames(snapshot).includes(parsed.parts[1].toLowerCase())
) {
return {
title: "APP STATUS",
message: `Unknown app '${parsed.parts[1]}'.`,
detail: webSlashAvailableAppsText(snapshot),
level: "error",
blocksSubmit: true,
};
}
if (verb === "skills") {
const action = parsed.parts[1];
if (["show", "enable", "disable"].includes(action) && parsed.parts.length === 3) {
const target = parsed.parts[2];
if (!webSlashResolveSkillTarget(snapshot, target)) {
return {
title: "SKILLS",
message: `Unknown skill '${target}'.`,
detail: "Use /skills to browse loaded skills.",
level: "error",
blocksSubmit: true,
};
}
} else if (action && !["list", "show", "enable", "disable", "reload"].includes(action)) {
return {
title: "SKILLS",
message: `Unknown skills action '${action}'.`,
detail: "Use /skills to choose an action.",
level: "error",
blocksSubmit: true,
};
}
}
if (verb === "sleep") {
const action = parsed.parts[1];
if (action && !["run", "status"].includes(action)) {
return {
title: "SLEEP",
message: `Unknown sleep action '${action}'.`,
detail: "Use /sleep to choose an action.",
level: "error",
blocksSubmit: true,
};
}
}
return null;
}
function webSlashExtraArgumentFeedback(
parts: string[],
): WebSlashCommandFeedback | null {
const [verb] = parts;
const rootNoArg = ["status", "clear", "restart", "workflows"];
if (rootNoArg.includes(verb) && parts.length > 1) {
return {
title: verb.toUpperCase(),
message: `/${verb} does not take extra arguments.`,
detail: `usage: /${verb}`,
level: "error",
blocksSubmit: true,
};
}
if (parts[0] === "debug" && parts.length > 2) {
return {
title: "DEBUG",
message: `debug ${parts[1]} does not take extra arguments.`,
detail: `usage: /debug ${parts[1]}`,
level: "error",
blocksSubmit: true,
};
}
if (parts[0] === "sleep" && parts.length > 2) {
return {
title: "SLEEP",
message: `sleep ${parts[1]} does not take extra arguments.`,
detail: `usage: /sleep ${parts[1]}`,
level: "error",
blocksSubmit: true,
};
}
if (
parts[0] === "skills" &&
(parts[1] === "list" || parts[1] === "reload") &&
parts.length > 2
) {
return {
title: "SKILLS",
message: `skills ${parts[1]} does not take extra arguments.`,
detail: `usage: /skills ${parts[1]}`,
level: "error",
blocksSubmit: true,
};
}
if (
parts[0] === "skills" &&
(parts[1] === "show" || parts[1] === "enable" || parts[1] === "disable") &&
parts.length === 2
) {
return {
title: "SKILLS",
message: `skills ${parts[1]} needs a skill name.`,
detail: `usage: /skills ${parts[1]} <skill>`,
level: "warning",
blocksSubmit: true,
};
}
if (
parts[0] === "skills" &&
(parts[1] === "show" || parts[1] === "enable" || parts[1] === "disable") &&
parts.length > 3
) {
return {
title: "SKILLS",
message: `skills ${parts[1]} accepts exactly one skill name.`,
detail: `usage: /skills ${parts[1]} <skill>`,
level: "error",
blocksSubmit: true,
};
}
if (webSlashCommandAccepts("app-status", parts[0]) && parts.length > 2) {
return {
title: "APP STATUS",
message: "app-status accepts exactly one app name.",
detail: "usage: /app-status <app>",
level: "error",
blocksSubmit: true,
};
}
return null;
}
function webSlashPanelForInput(
input: string,
snapshot: DashboardSnapshot | null,
): WebSlashPanel | null {
const parsed = parseWebSlashCommand(input);
if (!parsed) {
return null;
}
const parts = parsed.parts;
const [verb] = parts;
if (parts.length === 1) {
if (verb === "status") {
return { kind: "status" };
}
if (["debug", "sleep", "skills", "workflows"].includes(verb)) {
return { kind: "selection", panel: verb as Extract<WebSlashPanel, { kind: "selection" }>["panel"] };
}
if (webSlashCommandAccepts("app-status", verb)) {
const apps = webSlashAppNames(snapshot);
if (apps.length === 0) {
return {
kind: "detail",
title: "APP STATUS",
text: "No app state is currently available.",
};
}
return { kind: "selection", panel: "app-status" };
}
return null;
}
if (verb === "debug") {
if (parts[1] === "system-prompt" || parts[1] === "system_prompt") {
return {
kind: "detail",
title: "DEBUG SYSTEM PROMPT",
text: fallbackOutput(snapshot?.system_prompt_output),
};
}
if (
parts[1] === "context" ||
parts[1] === "preturn-context" ||
parts[1] === "preturn_context"
) {
return {
kind: "detail",
title: "DEBUG CONTEXT",
text: fallbackOutput(snapshot?.preturn_context_output),
};
}
}
if (verb === "sleep" && parts[1] === "status") {
return { kind: "sleep-status" };
}
if (webSlashCommandAccepts("app-status", verb) && parts.length === 2) {
const target = parts[1].toLowerCase();
const app = (snapshot?.app_status_outputs ?? []).find(([name]) => name === target);
if (app) {
return {
kind: "detail",
title: `APP STATUS ${target.toUpperCase()}`,
text: fallbackOutput(app[1]),
};
}
}
if (verb === "skills") {
if (parts[1] === "list" || (parts[1] === "show" && parts.length === 2)) {
return { kind: "skills-list", search: "" };
}
if (parts[1] === "show" && parts.length === 3) {
const skill = webSlashResolveSkillTarget(snapshot, parts[2]);
if (skill) {
return {
kind: "detail",
title: `SKILL ${skill.name}`,
text: webSlashSkillDetailText(skill),
};
}
}
}
return null;
}
function webSlashActionForInput(
input: string,
snapshot: DashboardSnapshot | null,
): DashboardAction | null {
const parsed = parseWebSlashCommand(input);
if (!parsed) {
return null;
}
const parts = parsed.parts;
if (parts.length === 1) {
if (parts[0] === "clear") {
return { kind: "clear_conversation" };
}
if (parts[0] === "restart") {
return { kind: "restart_daemon" };
}
return null;
}
if (parts[0] === "sleep" && parts[1] === "run") {
return { kind: "run_sleep" };
}
if (parts[0] === "skills" && parts[1] === "reload") {
return { kind: "reload_skills" };
}
if (
parts[0] === "skills" &&
(parts[1] === "enable" || parts[1] === "disable") &&
parts.length === 3
) {
const skill = webSlashResolveSkillTarget(snapshot, parts[2]);
if (!skill) {
return null;
}
return {
kind: "set_skill_auto_use",
path: skill.path,
enabled: parts[1] === "enable",
};
}
return null;
}
function webSlashActionFeedbackForResponse(
input: string,
output: string,
): WebSlashActionFeedback | null {
if (webSlashIsClearCommand(input)) {
return null;
}
const message = webSlashCompactMessage(output);
if (!message && !output.trim()) {
return null;
}
return {
command: input.trim(),
title: webSlashCommandTitle(input),
message,
detail: webSlashCommandDetail(output),
level: "info",
dismissible: true,
};
}
function webSlashActionFeedbackForResult(
commandLabel: string,
result: DashboardActionResult,
): WebSlashActionFeedback | null {
if (commandLabel.trim() === "/clear" && result.success) {
return null;
}
return {
command: commandLabel,
title: webSlashCommandTitle(commandLabel),
message: result.message,
detail: result.detail ?? undefined,
level: result.success ? "info" : "error",
dismissible: true,
};
}
function webSlashSelectionMeta(
panel: Extract<WebSlashPanel, { kind: "selection" }>["panel"],
snapshot: DashboardSnapshot | null,
) {
if (panel === "skills") {
const skills = snapshot?.skills ?? [];
const autoCount = skills.filter((skill) => skill.auto_use_enabled).length;
const manualCount = skills.length - autoCount;
return {
title: "Skills",
subtitle: `${skills.length} loaded, ${autoCount} auto-use, ${manualCount} manual-only`,
};
}
if (panel === "debug") {
return {
title: "Debug",
subtitle: "Inspect internal runtime views.",
};
}
if (panel === "sleep") {
return {
title: "Sleep",
subtitle: "Inspect sleep state or start a background sleep run.",
};
}
if (panel === "workflows") {
const workflows = snapshot?.workflows ?? [];
const errors = snapshot?.workflow_errors ?? [];
return {
title: "Workflows",
subtitle:
workflows.length > 0
? `${workflows.length} loaded Lua workflow${workflows.length === 1 ? "" : "s"}, ${errors.length} load error${errors.length === 1 ? "" : "s"}.`
: errors.length > 0
? `${errors.length} workflow load error${errors.length === 1 ? "" : "s"}.`
: "No Lua workflows are loaded.",
};
}
return {
title: "App Status",
subtitle: "Choose an app to inspect.",
};
}
function webSlashIsClearCommand(input: string) {
const parsed = parseWebSlashCommand(input);
return parsed?.parts[0] === "clear";
}
function webSlashCommandTitle(input: string) {
return (
parseWebSlashCommand(input)?.parts.join(" ").toUpperCase() || "COMMAND"
);
}
function webSlashCompactMessage(output: string) {
const first = output
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean);
return truncateText(first ?? "Done", 180);
}
function webSlashCommandDetail(output: string) {
const lines = output
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
return lines.length > 1 ? truncateText(lines.slice(1).join(" "), 220) : undefined;
}
function webSlashFindCommand(verb: string) {
return WEB_SLASH_COMMANDS.find(
(command) => command.name === verb || command.aliases?.includes(verb),
);
}
function webSlashCommandAccepts(primaryVerb: string, verb: string) {
const command = WEB_SLASH_COMMANDS.find((candidate) => candidate.name === primaryVerb);
return command?.name === verb || Boolean(command?.aliases?.includes(verb));
}
function webSlashRootSuggestion(
command: WebSlashCommandDefinition,
): WebInputSuggestion {
return {
display: command.name,
completion: `/${command.name}`,
description: command.description,
};
}
function webSlashSelectionItems(
panel: Extract<WebSlashPanel, { kind: "selection" }>["panel"],
snapshot: DashboardSnapshot | null,
): WebSlashSelectionItem[] {
if (panel === "debug") {
return [
{
id: "debug-persona",
name: "Prompt persona",
description: "show current prompt persona config",
},
{
id: "debug-system-prompt",
name: "System prompt",
description: "show current runtime system prompt",
},
{
id: "debug-context",
name: "Runtime context",
description: "show latest pre-turn runtime context",
},
];
}
if (panel === "sleep") {
return [
{
id: "sleep-status",
name: "Status",
description: "show sleep status",
},
{
id: "sleep-run",
name: "Start sleep run",
description: "start a background sleep run",
},
];
}
if (panel === "skills") {
const skills = snapshot?.skills ?? [];
return [
{
id: "skills-list",
name: "List skills",
description: "show loaded skills and load errors",
},
{
id: "skills-toggle",
name: "Enable/Disable Skills",
description: "toggle whether skills may be selected automatically",
disabled: skills.length === 0,
},
];
}
if (panel === "workflows") {
const workflows = (snapshot?.workflows ?? []).map((workflow) => ({
id: `workflow:${workflow.id}`,
name: workflow.id,
description:
workflow.input_fields.length === 0
? "No declared input fields"
: `${workflow.input_fields.length} typed input field${workflow.input_fields.length === 1 ? "" : "s"}`,
}));
const errors = (snapshot?.workflow_errors ?? []).map((error, index) => ({
id: `workflow-error:${index}`,
name: error.path,
description: error.message,
disabled: true,
}));
return [...workflows, ...errors];
}
return (snapshot?.app_status_outputs ?? []).map(([name, output]) => ({
id: `app-status:${name}`,
name,
description: truncateText(
output
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? "app state",
120,
),
}));
}
function webSlashRunSelectionItem(
itemId: string,
snapshot: DashboardSnapshot | null,
onSetPanel: (panel: WebSlashPanel | null) => void,
onRunAction: (command: string, detailTitle?: string) => void,
onRunDashboardAction: (
action: DashboardAction,
) => Promise<DashboardActionResult | null>,
) {
if (itemId === "debug-persona") {
onRunAction("/debug persona", "DEBUG PERSONA");
} else if (itemId === "debug-system-prompt") {
onSetPanel({
kind: "detail",
title: "DEBUG SYSTEM PROMPT",
text: fallbackOutput(snapshot?.system_prompt_output),
});
} else if (itemId === "debug-context") {
onSetPanel({
kind: "detail",
title: "DEBUG CONTEXT",
text: fallbackOutput(snapshot?.preturn_context_output),
});
} else if (itemId === "sleep-status") {
onSetPanel({ kind: "sleep-status" });
} else if (itemId === "sleep-run") {
void onRunDashboardAction({ kind: "run_sleep" });
} else if (itemId === "skills-list") {
onSetPanel({ kind: "skills-list", search: "" });
} else if (itemId === "skills-toggle") {
onSetPanel({ kind: "skills-toggle", search: "", feedback: null });
} else if (itemId.startsWith("workflow:")) {
const workflow = webSlashWorkflowById(
snapshot,
itemId.slice("workflow:".length),
);
if (workflow) {
onSetPanel({
kind: "workflow-form",
workflowId: workflow.id,
values: Object.fromEntries(
workflow.input_fields.map((field) => [
field.name,
webSlashWorkflowDefaultValueText(field.schema),
]),
) as Record<string, string>,
feedback: null,
});
}
} else if (itemId.startsWith("app-status:")) {
const appName = itemId.slice("app-status:".length);
const app = (snapshot?.app_status_outputs ?? []).find(([name]) => name === appName);
onSetPanel({
kind: "detail",
title: `APP STATUS ${appName.toUpperCase()}`,
text: fallbackOutput(app?.[1]),
});
}
}
function webSlashAppNames(snapshot: DashboardSnapshot | null) {
return (snapshot?.app_status_outputs ?? [])
.map(([name]) => name)
.filter(Boolean)
.sort();
}
function webSlashAvailableAppsText(snapshot: DashboardSnapshot | null) {
const apps = webSlashAppNames(snapshot);
return apps.length > 0
? `available: ${apps.join(", ")}`
: "No app state is currently available.";
}
function webSlashStatusSnapshot(
snapshot: DashboardSnapshot | null,
): WebSlashStatusSnapshotView {
const structured = snapshot?.status_command;
return {
planSteps:
structured?.plan_steps ??
(snapshot?.current_plan_step ? [snapshot.current_plan_step] : []),
};
}
function webSlashStatusTokenUsageSections(
snapshot: DashboardSnapshot | null,
): WebSlashStatusTokenUsageSectionData[] {
const tokenUsage = snapshot?.token_usage;
return [
webSlashStatusTokenUsageSection("main", tokenUsage?.main_model, tokenUsage?.main),
webSlashStatusTokenUsageSection("judge", tokenUsage?.judge_model, tokenUsage?.judge),
].filter((section): section is WebSlashStatusTokenUsageSectionData => Boolean(section));
}
function webSlashStatusTokenUsageSection(
role: string,
model: string | null | undefined,
info: TokenUsageInfo | null | undefined,
): WebSlashStatusTokenUsageSectionData | null {
if (!info || webSlashTokenUsageIsZero(info.total_token_usage)) {
return null;
}
const used = Math.max(0, info.last_token_usage.input_tokens);
const window = info.model_context_window;
const context =
typeof window === "number" && window > 0
? {
percent: used / window,
text: `${formatWebSlashCompactNumber(used)} of ${formatWebSlashCompactNumber(window)}`,
}
: null;
const lastTurnParts = webSlashTokenUsageParts(info.last_token_usage);
const totalTokens = Math.max(0, info.total_token_usage.total_tokens);
const cachedText =
info.total_token_usage.input_tokens > 0
? `${Math.round(
(info.total_token_usage.cached_input_tokens /
info.total_token_usage.input_tokens) *
100,
)}% cached`
: null;
return {
role,
model: model?.trim() || "<unknown>",
context,
lastTurnParts,
totalText: `${formatWebSlashCompactNumber(totalTokens)} Used`,
cachedText,
};
}
function webSlashTokenUsageParts(usage: {
input_tokens: number;
output_tokens: number;
reasoning_output_tokens: number;
total_tokens: number;
}) {
if (webSlashTokenUsageIsZero(usage)) {
return [];
}
const parts = [
`${formatWebSlashNumber(Math.max(0, usage.input_tokens))} in`,
`${formatWebSlashNumber(Math.max(0, usage.output_tokens))} out`,
];
if (usage.reasoning_output_tokens > 0) {
parts.push(
`${formatWebSlashNumber(Math.max(0, usage.reasoning_output_tokens))} reasoning`,
);
}
return parts;
}
function webSlashTokenUsageIsZero(usage: { total_tokens: number }) {
return Math.max(0, usage.total_tokens) === 0;
}
function webSlashPlanStatusBadgeVariant(
status: DashboardPlanStep["status"],
): WebSlashBadgeVariant {
if (status === "in_progress") {
return "default";
}
if (status === "completed") {
return "secondary";
}
return "outline";
}
function webSlashRuntimeOptimizationRows(
snapshot: DashboardSnapshot | null,
): Array<[string, string | number | null | undefined]> {
const runtime = snapshot?.runtime_optimization;
return [
["Running", runtime?.running ? "yes" : "no"],
["Trigger", runtime?.current_trigger],
["Last result", runtime?.last_result],
["Last completed", formatWebSlashTimestamp(runtime?.last_completed_at_ms)],
["Unread error backlog", runtime?.unread_runtime_error_backlog],
["Error cases consumed", runtime?.total_runtime_error_cases_consumed],
["Runtime cases", runtime?.total_runtime_error_cases],
["Reflections", runtime?.total_runtime_error_reflections],
["Contract candidates", runtime?.total_runtime_contract_candidates],
["Candidate evaluations", runtime?.total_runtime_contract_candidate_evaluations],
["System additions", runtime?.total_runtime_contract_system_additions],
["Contract updates", runtime?.total_runtime_contract_updates],
];
}
function webSlashPrimitiveOptimizationRows(
snapshot: DashboardSnapshot | null,
): Array<[string, string | number | null | undefined]> {
const primitive = snapshot?.primitive_optimization;
return [
["Running", primitive?.running ? "yes" : "no"],
["Trigger", primitive?.current_trigger],
["Last result", primitive?.last_result],
["Last completed", formatWebSlashTimestamp(primitive?.last_completed_at_ms)],
["Evidence records", primitive?.primitive_evidence_records],
["Evidence run records", primitive?.total_primitive_evidence_run_records],
["Reflections", primitive?.total_primitive_reflections],
["Patch candidates", primitive?.total_primitive_patch_candidates],
["Merge candidates", primitive?.total_primitive_merge_candidates],
["Candidate evaluations", primitive?.total_primitive_candidate_evaluations],
["Frontier entries", primitive?.total_primitive_frontier_entries],
["Frontier root entries", primitive?.latest_primitive_frontier_root_entries],
["Frontier branched entries", primitive?.latest_primitive_frontier_branched_entries],
["Frontier max generation", primitive?.latest_primitive_frontier_max_generation],
["Patch applied", primitive?.total_primitive_patch_applied],
["Merge applied", primitive?.total_primitive_merge_applied],
["Rollbacks", primitive?.total_primitive_update_rollbacks],
["Optimization rounds", primitive?.total_primitive_optimization_rounds],
];
}
function webSlashFilteredSkills(
snapshot: DashboardSnapshot | null,
query: string,
) {
const normalized = query.trim().toLowerCase();
const skills = snapshot?.skills ?? [];
if (!normalized) {
return skills;
}
return skills.filter((skill) =>
[skill.name, skill.description, skill.path, skill.scope].some((value) =>
value.toLowerCase().includes(normalized),
),
);
}
function webSlashResolveSkillTarget(
snapshot: DashboardSnapshot | null,
target: string,
) {
const skills = snapshot?.skills ?? [];
return (
skills.find((skill) => skill.path === target) ??
skills.find((skill) => skill.name === target) ??
null
);
}
function webSlashSkillDetailText(
skill: NonNullable<DashboardSnapshot["skills"]>[number],
) {
return [
`Name: ${skill.name}`,
`Status: ${webSlashSkillStatusDescription(skill)}`,
`Scope: ${skill.scope}`,
`Path: ${skill.path}`,
`Description: ${skill.description}`,
].join("\n");
}
function webSlashSkillStatusDescription(
skill: NonNullable<DashboardSnapshot["skills"]>[number],
) {
if (skill.auto_use_enabled) {
return "auto-use enabled";
}
if (skill.user_disabled) {
return "manual-only: disabled by /skills";
}
if (!skill.allow_implicit_invocation) {
return "manual-only: policy disallows implicit invocation";
}
return "manual-only";
}
function fallbackOutput(output: string | null | undefined) {
return output?.trim() ? output : "no data";
}
function renderWebSlashDetailLines(text: string) {
const lines = fallbackOutput(text).split(/\r?\n/);
let previousBlank = true;
return lines.map((rawLine) => {
const line = rawLine.trimEnd();
let kind: "blank" | "header" | "bullet" | "label" | "text" = "text";
if (!line.trim()) {
previousBlank = true;
return { kind: "blank", text: "" };
}
if (previousBlank && line.length < 72 && !line.includes(":")) {
kind = "header";
} else if (line.startsWith("• ")) {
kind = "bullet";
} else if (line.includes(":")) {
kind = "label";
}
previousBlank = false;
return { kind, text: line };
});
}
function formatWebSlashValue(value: string | number | null | undefined) {
if (value === null || value === undefined || value === "") {
return "none";
}
return String(value);
}
function formatWebSlashCompactNumber(value: number) {
return new Intl.NumberFormat("en", {
compactDisplay: "short",
maximumFractionDigits: value >= 1000 ? 1 : 0,
notation: "compact",
}).format(value);
}
function formatWebSlashNumber(value: number) {
return new Intl.NumberFormat("en").format(value);
}
function formatWebSlashTimestamp(timestampMs: number | null | undefined) {
if (timestampMs === null || timestampMs === undefined) {
return null;
}
try {
return new Date(timestampMs).toLocaleString();
} catch {
return String(timestampMs);
}
}
function truncateText(text: string, maxLength: number) {
return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
}
function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => {
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(new Error("Failed to read image."));
}
});
reader.addEventListener("error", () => {
reject(reader.error ?? new Error("Failed to read image."));
});
reader.readAsDataURL(file);
});
}
function formatFileSize(bytes: number) {
if (!Number.isFinite(bytes) || bytes <= 0) {
return "0 B";
}
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
const maximumFractionDigits = value >= 10 || unitIndex === 0 ? 0 : 1;
return `${value.toFixed(maximumFractionDigits)} ${units[unitIndex]}`;
}
function useActivityFollowBottom<T extends HTMLElement>(
viewportRef: RefObject<T | null>,
contentKey: unknown,
resetKey: unknown,
) {
const followBottomRef = useRef(true);
const manualScrollTopRef = useRef<number | null>(null);
const prependRestoreRef = useRef<{
scrollHeight: number;
scrollTop: number;
} | null>(null);
const previousResetKeyRef = useRef(resetKey);
const isNearBottom = useCallback((viewport: T) => {
return (
viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop <=
AGENT_CHAT_STICKY_BOTTOM_THRESHOLD_PX
);
}, []);
const syncViewport = useCallback(
(viewport: T) => {
followBottomRef.current = isNearBottom(viewport);
manualScrollTopRef.current = viewport.scrollTop;
},
[isNearBottom],
);
const scrollToBottom = useCallback(
(behavior: ScrollBehavior = "auto") => {
const viewport = viewportRef.current;
if (!viewport) {
return;
}
followBottomRef.current = true;
viewport.scrollTo({ top: viewport.scrollHeight, behavior });
manualScrollTopRef.current = viewport.scrollTop;
},
[viewportRef],
);
const scrollTo = useCallback(
(top: number, behavior: ScrollBehavior = "auto") => {
const viewport = viewportRef.current;
if (!viewport) {
return;
}
const boundedTop = Math.max(
0,
Math.min(top, Math.max(0, viewport.scrollHeight - viewport.clientHeight)),
);
viewport.scrollTo({ top: boundedTop, behavior });
manualScrollTopRef.current = boundedTop;
followBottomRef.current =
Math.max(0, viewport.scrollHeight - viewport.clientHeight) - boundedTop <=
AGENT_CHAT_STICKY_BOTTOM_THRESHOLD_PX;
},
[viewportRef],
);
const handleScroll = useCallback(
(event: UIEvent<T>) => syncViewport(event.currentTarget),
[syncViewport],
);
const captureBeforePrepend = useCallback(() => {
const viewport = viewportRef.current;
if (!viewport || followBottomRef.current) {
return;
}
prependRestoreRef.current = {
scrollHeight: viewport.scrollHeight,
scrollTop: viewport.scrollTop,
};
}, [viewportRef]);
const resetToBottom = useCallback(() => {
followBottomRef.current = true;
manualScrollTopRef.current = null;
prependRestoreRef.current = null;
}, []);
useLayoutEffect(() => {
const viewport = viewportRef.current;
if (!viewport) {
return;
}
if (previousResetKeyRef.current !== resetKey) {
previousResetKeyRef.current = resetKey;
resetToBottom();
}
if (followBottomRef.current) {
scrollToBottom();
return;
}
const restore = prependRestoreRef.current;
if (restore) {
prependRestoreRef.current = null;
scrollTo(viewport.scrollHeight - restore.scrollHeight + restore.scrollTop);
return;
}
if (manualScrollTopRef.current !== null) {
scrollTo(manualScrollTopRef.current);
}
}, [contentKey, resetKey, resetToBottom, scrollTo, scrollToBottom, viewportRef]);
return {
followBottomRef,
scrollToBottom,
scrollTo,
handleScroll,
captureBeforePrepend,
resetToBottom,
};
}
function AgentChatBubbles({
sessionId,
snapshot,
panelRef,
composerHeight,
}: {
sessionId: string;
snapshot: DashboardSnapshot | null;
panelRef: RefObject<HTMLDivElement | null>;
composerHeight: number;
}) {
const snapshotBubbles = useMemo(
() => agentChatBubblesFromSnapshot(snapshot),
[snapshot],
);
const [historyBubbles, setHistoryBubbles] = useState<AgentChatBubble[]>([]);
const [oldestCursor, setOldestCursor] = useState<number | null>(null);
const [hasMoreBefore, setHasMoreBefore] = useState(false);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
const [navHistoryBubbles, setNavHistoryBubbles] = useState<AgentChatBubble[]>([]);
const [navOldestCursor, setNavOldestCursor] = useState<number | null>(null);
const [hasMoreNavBefore, setHasMoreNavBefore] = useState(false);
const [isLoadingNavHistory, setIsLoadingNavHistory] = useState(false);
const [navHistoryError, setNavHistoryError] = useState<string | null>(null);
const [workflowInspectorSnapshot, setWorkflowInspectorSnapshot] =
useState<WorkflowRunSnapshot | null>(null);
const navHistoryAbortRef = useRef<AbortController | null>(null);
const historySessionIdRef = useRef<string | null>(null);
const loadedOlderHistoryRef = useRef(false);
const navHistorySessionIdRef = useRef<string | null>(null);
const navHistoryInitializedRef = useRef(false);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const bubbles = useMemo(
() => mergeAgentChatBubbles(historyBubbles, snapshotBubbles),
[historyBubbles, snapshotBubbles],
);
const workflowInspectorSnapshots = useMemo(() => {
const snapshots = new Map<string, WorkflowRunSnapshot>();
for (const bubble of bubbles) {
const workflowSnapshot = workflowSnapshotFromActivityBubble(bubble);
if (workflowSnapshot) {
snapshots.set(workflowSnapshot.run_id, workflowSnapshot);
}
}
for (const workflowSnapshot of snapshot?.active_workflow_runs ?? []) {
snapshots.set(workflowSnapshot.run_id, workflowSnapshot);
}
return snapshots;
}, [bubbles, snapshot?.active_workflow_runs]);
const primaryWorkflowSnapshot = workflowInspectorSnapshot
? workflowInspectorSnapshots.get(workflowInspectorSnapshot.run_id) ?? workflowInspectorSnapshot
: null;
const visibleWorkflowSnapshot = primaryWorkflowSnapshot;
const openWorkflowInspector = useCallback((workflowSnapshot: WorkflowRunSnapshot) => {
setWorkflowInspectorSnapshot(workflowSnapshot);
}, []);
useEffect(() => {
setWorkflowInspectorSnapshot((current) => {
if (!current) {
return null;
}
return workflowInspectorSnapshots.get(current.run_id) ?? current;
});
}, [workflowInspectorSnapshots]);
useEffect(() => {
setWorkflowInspectorSnapshot(null);
}, [sessionId]);
const activityScroll = useActivityFollowBottom<HTMLDivElement>(
panelRef,
bubbles,
sessionId,
);
const activeRuntimeStatusBubbleId = useMemo(() => {
const activeRuntimeStatusBubble = bubbles.find(
(bubble) =>
(bubble.live || bubble.status === "running") &&
agentChatBubbleHasSessionActivityEvent(bubble, "RuntimeStatus"),
);
return activeRuntimeStatusBubble?.id ?? null;
}, [bubbles]);
const latestReplyBubbleId = useMemo(() => {
if (activeRuntimeStatusBubbleId) {
return null;
}
for (let index = bubbles.length - 1; index >= 0; index -= 1) {
const bubble = bubbles[index];
if (bubble && agentChatBubbleHasSessionActivityEvent(bubble, "Reply")) {
return bubble.id;
}
}
return null;
}, [activeRuntimeStatusBubbleId, bubbles]);
const activeAgentExpressionSlotKey = activeRuntimeStatusBubbleId
? agentChatExpressionSlotKey("runtime", activeRuntimeStatusBubbleId)
: latestReplyBubbleId
? agentChatExpressionSlotKey("reply", latestReplyBubbleId)
: null;
const prefersReducedMotion = useAgentChatPrefersReducedMotion();
const expressionSlotsRef = useRef(
new Map<string, AgentChatExpressionSlotRegistration>(),
);
const expressionSlotSnapshotsRef = useRef(
new Map<string, AgentChatExpressionSlotSnapshot>(),
);
const previousActiveExpressionSlotKeyRef = useRef<string | null>(null);
const expressionTransitionIdRef = useRef(0);
const expressionTransitionFrameRef = useRef<number | null>(null);
const expressionTransitionTimeoutRef = useRef<number | null>(null);
const [expressionTransition, setExpressionTransition] =
useState<AgentChatExpressionTransition | null>(null);
const [expressionTransitionActive, setExpressionTransitionActive] =
useState(false);
const registerAgentExpressionSlot = useCallback(
(
slotKey: string,
element: HTMLElement | null,
meta: AgentChatExpressionSlotMeta,
) => {
if (element) {
expressionSlotsRef.current.set(slotKey, { ...meta, element });
} else {
expressionSlotsRef.current.delete(slotKey);
}
},
[],
);
const displayItems = useMemo(
() =>
foldCompletedAgentChatActivity(bubbles, {
isOutputBoundary: agentChatBubbleIsOutputBoundary,
}),
[bubbles],
);
const [openFoldedActivityGroups, setOpenFoldedActivityGroups] = useState<
Record<string, boolean>
>({});
const [activeQuickNavItemId, setActiveQuickNavItemId] = useState<
string | null
>(null);
const quickNavItems = useMemo(() => {
const allNavBubbles = mergeAgentChatBubbles(navHistoryBubbles, bubbles);
return allNavBubbles
.flatMap((bubble): AgentChatQuickNavItem[] => {
const label = agentChatQuickNavLabelForBubble(bubble);
return label
? [
{
id: bubble.id,
label,
order: agentChatQuickNavOrderForBubble(bubble),
},
]
: [];
})
.sort(agentChatQuickNavItemCompare);
}, [bubbles, navHistoryBubbles]);
const visibleQuickNavItems = useMemo(
() => quickNavItems.slice(-AGENT_CHAT_QUICK_NAV_MAX_ITEMS),
[quickNavItems],
);
const displayQuickNavTargets = useMemo(() => {
const visibleQuickNavItemIds = new Set(
visibleQuickNavItems.map((item) => item.id),
);
return displayItems
.map((item): AgentChatQuickNavDisplayTarget | null => {
const quickNavItemId = agentChatFoldDisplayItemQuickNavTargetId(item);
if (!quickNavItemId || !visibleQuickNavItemIds.has(quickNavItemId)) {
return null;
}
return { id: item.id, quickNavItemId };
})
.filter((item): item is AgentChatQuickNavDisplayTarget => Boolean(item));
}, [displayItems, visibleQuickNavItems]);
const navReachedMax = quickNavItems.length >= AGENT_CHAT_QUICK_NAV_MAX_ITEMS;
const displayItemElementsRef = useRef(new Map<string, HTMLDivElement>());
const [pendingQuickNavTargetId, setPendingQuickNavTargetId] = useState<
string | null
>(null);
const pendingFoldCollapseAnchorRef = useRef<{
id: string;
top: number;
} | null>(null);
const registerDisplayItemElement = useCallback(
(id: string, node: HTMLDivElement | null) => {
if (node) {
displayItemElementsRef.current.set(id, node);
} else {
displayItemElementsRef.current.delete(id);
}
},
[],
);
const updateQuickNavActiveItem = useCallback(
(panel: HTMLDivElement) => {
if (visibleQuickNavItems.length === 0) {
setActiveQuickNavItemId((current) => (current === null ? current : null));
return;
}
const panelRect = panel.getBoundingClientRect();
const targetY = panelRect.top + Math.min(panelRect.height * 0.38, 240);
let nextActiveId: string | null = null;
let bestDistance = Number.POSITIVE_INFINITY;
for (const target of displayQuickNavTargets) {
const element = displayItemElementsRef.current.get(target.id);
if (!element) {
continue;
}
const rect = element.getBoundingClientRect();
if (rect.bottom < panelRect.top || rect.top > panelRect.bottom) {
continue;
}
const distance = Math.abs(rect.top - targetY);
if (distance < bestDistance) {
bestDistance = distance;
nextActiveId = target.quickNavItemId;
}
}
nextActiveId ??= visibleQuickNavItems[0]?.id ?? null;
setActiveQuickNavItemId((current) =>
current === nextActiveId ? current : nextActiveId,
);
},
[displayQuickNavTargets, visibleQuickNavItems],
);
const handleFoldedActivityGroupOpenChange = useCallback(
(id: string, nextOpen: boolean) => {
if (!nextOpen) {
const panel = panelRef.current;
const element = displayItemElementsRef.current.get(id);
const header = element?.querySelector<HTMLElement>(
"[data-agent-chat-fold-header='true']",
);
if (panel && element) {
pendingFoldCollapseAnchorRef.current = {
id,
top: (header ?? element).getBoundingClientRect().top,
};
}
}
setOpenFoldedActivityGroups((current) => {
if (Boolean(current[id]) === nextOpen) {
return current;
}
if (!nextOpen) {
const next = { ...current };
delete next[id];
return next;
}
return { ...current, [id]: true };
});
},
[panelRef],
);
const scrollToChatBottom = useCallback(
(behavior: ScrollBehavior = "auto") => activityScroll.scrollToBottom(behavior),
[activityScroll.scrollToBottom],
);
function updateScrollButtonVisibility(panel: HTMLDivElement) {
const distanceFromBottom =
panel.scrollHeight - panel.clientHeight - panel.scrollTop;
setShowScrollToBottom(distanceFromBottom > AGENT_CHAT_SCROLL_BUTTON_THRESHOLD_PX);
}
function handleScroll(event: UIEvent<HTMLDivElement>) {
const panel = event.currentTarget;
const distanceFromBottom =
panel.scrollHeight - panel.clientHeight - panel.scrollTop;
activityScroll.handleScroll(event);
setShowScrollToBottom(distanceFromBottom > AGENT_CHAT_SCROLL_BUTTON_THRESHOLD_PX);
updateQuickNavActiveItem(panel);
if (
panel.scrollTop <= AGENT_CHAT_STICKY_BOTTOM_THRESHOLD_PX &&
hasMoreBefore &&
!isLoadingHistory
) {
void loadOlderHistory();
}
}
function handleScrollToBottomClick() {
scrollToChatBottom("smooth");
setShowScrollToBottom(false);
}
useEffect(() => {
const panel = panelRef.current;
if (!panel) {
return;
}
updateQuickNavActiveItem(panel);
}, [visibleQuickNavItems, panelRef, updateQuickNavActiveItem]);
const scrollToQuickNavTarget = useCallback(
(id: string, behavior: ScrollBehavior = "smooth") => {
const panel = panelRef.current;
const element = displayItemElementsRef.current.get(id);
if (!panel || !element) {
return false;
}
const panelRect = panel.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const top = Math.max(
0,
panel.scrollTop +
elementRect.top -
panelRect.top -
AGENT_CHAT_QUICK_NAV_SCROLL_OFFSET_PX,
);
activityScroll.scrollTo(top, behavior);
const distanceFromBottom = panel.scrollHeight - panel.clientHeight - panel.scrollTop;
setActiveQuickNavItemId(id);
setShowScrollToBottom(
distanceFromBottom > AGENT_CHAT_SCROLL_BUTTON_THRESHOLD_PX,
);
return true;
},
[activityScroll.scrollTo, panelRef],
);
const handleQuickNavSelect = useCallback(
(id: string) => {
setActiveQuickNavItemId(id);
if (scrollToQuickNavTarget(id)) {
setPendingQuickNavTargetId(null);
return;
}
setPendingQuickNavTargetId(id);
},
[scrollToQuickNavTarget],
);
const loadOlderHistory = useCallback(async () => {
if (
isLoadingHistory ||
!hasMoreBefore ||
oldestCursor === null
) {
return;
}
activityScroll.captureBeforePrepend();
setIsLoadingHistory(true);
setHistoryError(null);
try {
const page = await fetchDashboardActivityHistory({
before: oldestCursor,
limit: AGENT_CHAT_HISTORY_PAGE_LIMIT,
sessionId,
});
const olderBubbles = agentChatBubblesFromHistoryPage(page);
if (olderBubbles.length > 0) {
loadedOlderHistoryRef.current = true;
}
setHistoryBubbles((current) =>
mergeAgentChatBubbles(olderBubbles, current),
);
setNavHistoryBubbles((current) =>
mergeAgentChatBubbles(olderBubbles, current),
);
setOldestCursor(page.oldest_cursor ?? oldestCursor);
setNavOldestCursor((current) => {
const nextCursor = page.oldest_cursor ?? current;
if (nextCursor === null) {
return null;
}
return current === null ? nextCursor : Math.min(current, nextCursor);
});
setHasMoreBefore(page.has_more_before);
setHasMoreNavBefore(page.has_more_before);
} catch (error) {
setHistoryError(error instanceof Error ? error.message : String(error));
} finally {
setIsLoadingHistory(false);
}
}, [
activityScroll.captureBeforePrepend,
hasMoreBefore,
isLoadingHistory,
oldestCursor,
sessionId,
]);
useEffect(() => {
if (!pendingQuickNavTargetId) {
return;
}
if (scrollToQuickNavTarget(pendingQuickNavTargetId)) {
setPendingQuickNavTargetId(null);
return;
}
if (!hasMoreBefore || isLoadingHistory || oldestCursor === null) {
if (!hasMoreBefore) {
setPendingQuickNavTargetId(null);
}
return;
}
void loadOlderHistory();
}, [
displayItems.length,
hasMoreBefore,
isLoadingHistory,
loadOlderHistory,
oldestCursor,
pendingQuickNavTargetId,
scrollToQuickNavTarget,
]);
const loadOlderNavHistory = useCallback(async () => {
if (
isLoadingNavHistory ||
!hasMoreNavBefore ||
navOldestCursor === null ||
navReachedMax
) {
return;
}
navHistoryAbortRef.current?.abort();
const controller = new AbortController();
navHistoryAbortRef.current = controller;
setIsLoadingNavHistory(true);
setNavHistoryError(null);
try {
const page = await fetchDashboardActivityHistory({
before: navOldestCursor,
limit: AGENT_CHAT_NAV_HISTORY_PAGE_LIMIT,
sessionId,
signal: controller.signal,
});
const olderBubbles = agentChatBubblesFromHistoryPage(page);
setNavHistoryBubbles((current) =>
mergeAgentChatBubbles(olderBubbles, current),
);
setNavOldestCursor(page.oldest_cursor ?? navOldestCursor);
setHasMoreNavBefore(page.has_more_before);
} catch (error) {
if (!controller.signal.aborted) {
setNavHistoryError(error instanceof Error ? error.message : String(error));
}
} finally {
if (navHistoryAbortRef.current === controller) {
navHistoryAbortRef.current = null;
}
if (!controller.signal.aborted) {
setIsLoadingNavHistory(false);
}
}
}, [
hasMoreNavBefore,
isLoadingNavHistory,
navOldestCursor,
navReachedMax,
sessionId,
]);
useEffect(() => {
navHistoryAbortRef.current?.abort();
navHistoryAbortRef.current = null;
navHistorySessionIdRef.current = null;
navHistoryInitializedRef.current = false;
setPendingQuickNavTargetId(null);
setNavHistoryBubbles([]);
setNavOldestCursor(null);
setHasMoreNavBefore(false);
setIsLoadingNavHistory(false);
setNavHistoryError(null);
}, [sessionId]);
useEffect(() => {
const historyWindow = snapshot?.activity_history;
const committedBubbles = agentChatCommittedBubblesFromSnapshot(snapshot);
const snapshotOldestCursor = historyWindow?.oldest_cursor ?? null;
const snapshotNewestCursor = historyWindow?.newest_cursor ?? null;
const sessionChanged = historySessionIdRef.current !== sessionId;
const historyCleared =
committedBubbles.length === 0 && snapshotNewestCursor === null;
historySessionIdRef.current = sessionId;
if (sessionChanged || historyCleared || !loadedOlderHistoryRef.current) {
loadedOlderHistoryRef.current = false;
setHistoryBubbles(committedBubbles);
setOldestCursor(snapshotOldestCursor);
setHasMoreBefore(Boolean(historyWindow?.has_more_before));
} else {
setHistoryBubbles((current) =>
mergeAgentChatBubbles(current, committedBubbles),
);
setOldestCursor((current) => {
if (snapshotOldestCursor === null) {
return current;
}
if (current === null) {
return snapshotOldestCursor;
}
return Math.min(current, snapshotOldestCursor);
});
}
const navSessionChanged = navHistorySessionIdRef.current !== sessionId;
navHistorySessionIdRef.current = sessionId;
if (navSessionChanged || historyCleared || !navHistoryInitializedRef.current) {
navHistoryInitializedRef.current = true;
setNavHistoryBubbles(committedBubbles);
setNavOldestCursor(snapshotOldestCursor);
setHasMoreNavBefore(Boolean(historyWindow?.has_more_before));
} else {
setNavHistoryBubbles((current) =>
mergeAgentChatBubbles(current, committedBubbles),
);
setNavOldestCursor((current) => {
if (snapshotOldestCursor === null) {
return current;
}
if (current === null) {
return snapshotOldestCursor;
}
return Math.min(current, snapshotOldestCursor);
});
setHasMoreNavBefore((current) =>
current || Boolean(historyWindow?.has_more_before),
);
}
setHistoryError(null);
setNavHistoryError(null);
}, [sessionId, snapshot?.activity_history?.newest_cursor]);
// Auto-load older history when content doesn't fill the viewport
useEffect(() => {
const panel = panelRef.current;
if (
!panel ||
isLoadingHistory ||
!hasMoreBefore ||
oldestCursor === null
) {
return;
}
if (panel.scrollHeight <= panel.clientHeight) {
loadOlderHistory();
}
}, [
hasMoreBefore,
isLoadingHistory,
oldestCursor,
historyBubbles.length,
loadOlderHistory,
]);
useEffect(() => {
const panel = panelRef.current;
if (!panel) {
return;
}
updateScrollButtonVisibility(panel);
updateQuickNavActiveItem(panel);
}, [bubbles, panelRef, updateQuickNavActiveItem]);
useEffect(() => {
const foldedIds = new Set(
displayItems
.filter((item) => item.kind === "foldedActivityGroup")
.map((item) => item.id),
);
setOpenFoldedActivityGroups((current) => {
let changed = false;
const next: Record<string, boolean> = {};
for (const [id, open] of Object.entries(current)) {
if (foldedIds.has(id)) {
next[id] = open;
} else {
changed = true;
}
}
return changed ? next : current;
});
}, [displayItems]);
useLayoutEffect(() => {
const anchor = pendingFoldCollapseAnchorRef.current;
if (!anchor) {
return;
}
pendingFoldCollapseAnchorRef.current = null;
const panel = panelRef.current;
const element = displayItemElementsRef.current.get(anchor.id);
if (!panel || !element) {
return;
}
const nextTop = element.getBoundingClientRect().top;
activityScroll.scrollTo(panel.scrollTop + nextTop - anchor.top);
updateScrollButtonVisibility(panel);
updateQuickNavActiveItem(panel);
}, [
activityScroll.scrollTo,
openFoldedActivityGroups,
panelRef,
updateQuickNavActiveItem,
]);
useLayoutEffect(() => {
const previousKey = previousActiveExpressionSlotKeyRef.current;
const nextKey = activeAgentExpressionSlotKey;
const previousSnapshot = previousKey
? expressionSlotSnapshotsRef.current.get(previousKey)
: undefined;
const nextRegistration = nextKey
? expressionSlotsRef.current.get(nextKey)
: undefined;
if (prefersReducedMotion || !nextKey) {
if (expressionTransitionFrameRef.current !== null) {
window.cancelAnimationFrame(expressionTransitionFrameRef.current);
expressionTransitionFrameRef.current = null;
}
if (expressionTransitionTimeoutRef.current !== null) {
window.clearTimeout(expressionTransitionTimeoutRef.current);
expressionTransitionTimeoutRef.current = null;
}
setExpressionTransition(null);
setExpressionTransitionActive(false);
} else if (
previousKey &&
previousKey !== nextKey &&
previousSnapshot &&
nextRegistration
) {
const nextRect = agentChatExpressionRectFromElement(
nextRegistration.element,
);
if (
agentChatExpressionRectIsVisible(previousSnapshot.rect) &&
agentChatExpressionRectIsVisible(nextRect)
) {
if (expressionTransitionFrameRef.current !== null) {
window.cancelAnimationFrame(expressionTransitionFrameRef.current);
}
if (expressionTransitionTimeoutRef.current !== null) {
window.clearTimeout(expressionTransitionTimeoutRef.current);
}
const nextTransition: AgentChatExpressionTransition = {
id: expressionTransitionIdRef.current + 1,
toKey: nextKey,
fromRect: previousSnapshot.rect,
toRect: nextRect,
status: nextRegistration.status,
className: nextRegistration.className,
};
expressionTransitionIdRef.current = nextTransition.id;
setExpressionTransition(nextTransition);
setExpressionTransitionActive(false);
expressionTransitionFrameRef.current = window.requestAnimationFrame(
() => {
expressionTransitionFrameRef.current = window.requestAnimationFrame(
() => {
expressionTransitionFrameRef.current = null;
const latestRegistration =
expressionSlotsRef.current.get(nextKey) ?? nextRegistration;
const latestPreviousRegistration = previousKey
? expressionSlotsRef.current.get(previousKey)
: undefined;
const latestFromRect = latestPreviousRegistration
? agentChatExpressionRectFromElement(
latestPreviousRegistration.element,
)
: previousSnapshot.rect;
const latestToRect = agentChatExpressionRectFromElement(
latestRegistration.element,
);
if (
!agentChatExpressionRectIsVisible(latestFromRect) ||
!agentChatExpressionRectIsVisible(latestToRect)
) {
setExpressionTransition(null);
setExpressionTransitionActive(false);
return;
}
setExpressionTransition((current) =>
current?.id === nextTransition.id
? {
...current,
className: latestRegistration.className,
fromRect: latestFromRect,
status: latestRegistration.status,
toRect: latestToRect,
}
: current,
);
setExpressionTransitionActive(true);
},
);
},
);
expressionTransitionTimeoutRef.current = window.setTimeout(() => {
setExpressionTransition((current) =>
current?.id === nextTransition.id ? null : current,
);
setExpressionTransitionActive(false);
expressionTransitionTimeoutRef.current = null;
}, AGENT_CHAT_EXPRESSION_TRANSITION_MS + 120);
} else {
setExpressionTransition(null);
setExpressionTransitionActive(false);
}
}
const nextSnapshots = new Map<string, AgentChatExpressionSlotSnapshot>();
expressionSlotsRef.current.forEach((registration, slotKey) => {
nextSnapshots.set(slotKey, {
status: registration.status,
className: registration.className,
rect: agentChatExpressionRectFromElement(registration.element),
});
});
expressionSlotSnapshotsRef.current = nextSnapshots;
previousActiveExpressionSlotKeyRef.current = nextKey;
});
useEffect(() => {
return () => {
if (expressionTransitionFrameRef.current !== null) {
window.cancelAnimationFrame(expressionTransitionFrameRef.current);
}
if (expressionTransitionTimeoutRef.current !== null) {
window.clearTimeout(expressionTransitionTimeoutRef.current);
}
};
}, []);
const agentExpressionTransitionContextValue =
useMemo<AgentChatExpressionTransitionContextValue>(
() => ({
hiddenSlotKey: expressionTransition?.toKey ?? null,
registerSlot: registerAgentExpressionSlot,
}),
[expressionTransition?.toKey, registerAgentExpressionSlot],
);
return (
<AgentChatExpressionTransitionContext.Provider
value={agentExpressionTransitionContextValue}
>
<>
<div
ref={panelRef}
aria-label="Agent activity"
onScroll={handleScroll}
style={{
paddingBottom: composerHeight + AGENT_CHAT_COMPOSER_BOTTOM_GAP_PX,
}}
className={cn(
"relative z-10 min-h-0 w-full max-w-full flex-1 overflow-x-hidden overflow-y-auto text-left [scrollbar-gutter:stable] [scrollbar-width:thin]",
)}
>
<div className="relative z-10 flex min-h-full w-full min-w-0 max-w-full flex-col justify-end">
{displayItems.length > 0 ? (
<div
className={cn(
"mx-auto flex w-full min-w-0 max-w-5xl flex-col gap-3 overflow-x-hidden px-2 py-4 sm:px-4 md:px-6",
)}
>
{isLoadingHistory || historyError ? (
<div className="flex justify-center py-1">
{isLoadingHistory ? (
<div className="flex items-center gap-2 rounded-full border border-border/70 bg-background/80 px-3 py-1 text-xs text-muted-foreground shadow-sm backdrop-blur-xl">
<Spinner data-icon="inline-start" />
Loading older…
</div>
) : null}
{historyError ? (
<Alert variant="destructive" className="max-w-xl px-2 py-1">
<AlertDescription className="text-xs">
{historyError}
</AlertDescription>
</Alert>
) : null}
</div>
) : null}
{displayItems.map((item) => (
<div
key={item.id}
ref={(node) => registerDisplayItemElement(item.id, node)}
className="min-w-0 max-w-full"
>
{item.kind === "bubble" ? (
<AgentChatBubbleItem
sessionId={sessionId}
bubble={item.bubble}
activeRuntimeStatusBubbleId={activeRuntimeStatusBubbleId}
isLatestReply={item.bubble.id === latestReplyBubbleId}
onOpenWorkflowInspector={openWorkflowInspector}
/>
) : (
<AgentChatFoldedActivityGroup
id={item.id}
sessionId={sessionId}
bubbles={item.bubbles}
outputBubble={item.outputBubble}
activeRuntimeStatusBubbleId={activeRuntimeStatusBubbleId}
latestReplyBubbleId={latestReplyBubbleId}
onOpenWorkflowInspector={openWorkflowInspector}
open={Boolean(openFoldedActivityGroups[item.id])}
onOpenChange={(nextOpen) =>
handleFoldedActivityGroupOpenChange(item.id, nextOpen)
}
/>
)}
</div>
))}
</div>
) : (
<div className="mx-auto flex min-h-[40vh] w-full min-w-0 max-w-3xl items-center justify-center px-6 text-center">
<Empty className="border border-dashed bg-card/60">
<EmptyHeader>
<EmptyTitle>No activity yet</EmptyTitle>
<EmptyDescription>
Messages and tool activity will appear here as the session
starts working.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
)}
</div>
</div>
{visibleWorkflowSnapshot ? (
<WorkflowInspectorDialog
sessionId={sessionId}
snapshot={visibleWorkflowSnapshot}
open
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setWorkflowInspectorSnapshot(null);
}
}}
/>
) : null}
<AgentChatQuickNavigation
items={visibleQuickNavItems}
activeItemId={activeQuickNavItemId}
resetKey={sessionId}
hasMoreBefore={hasMoreNavBefore && !navReachedMax}
isLoadingHistory={isLoadingNavHistory}
historyError={navHistoryError}
onNearTop={() => {
void loadOlderNavHistory();
}}
onSelect={handleQuickNavSelect}
/>
<Button
type="button"
variant="secondary"
size="icon-lg"
aria-label="Back to bottom"
title="Back to bottom"
onMouseDown={(event) => {
event.preventDefault();
}}
onClick={handleScrollToBottomClick}
style={{
bottom: `calc(max(1.25rem, env(safe-area-inset-bottom)) + ${
composerHeight + AGENT_CHAT_COMPOSER_BOTTOM_GAP_PX
}px)`,
}}
className={cn(
"fixed left-1/2 z-40 -translate-x-1/2 rounded-full border border-border/70 bg-background/90 shadow-lg shadow-background/30 backdrop-blur-xl transition-all duration-200 md:left-[calc(18rem+(100vw-18rem)/2)]",
showScrollToBottom
? "pointer-events-auto translate-y-0 opacity-100"
: "pointer-events-none translate-y-2 opacity-0",
)}
>
<ArrowDownIcon data-icon="inline-start" aria-hidden="true" />
</Button>
{typeof document !== "undefined" && expressionTransition
? createPortal(
<AgentChatExpressionTransitionOverlay
active={expressionTransitionActive}
transition={expressionTransition}
/>,
document.body,
)
: null}
</>
</AgentChatExpressionTransitionContext.Provider>
);
}
function AgentChatQuickNavigation({
items,
activeItemId,
resetKey,
hasMoreBefore,
isLoadingHistory,
historyError,
onNearTop,
onSelect,
}: {
items: AgentChatQuickNavItem[];
activeItemId: string | null;
resetKey: string;
hasMoreBefore: boolean;
isLoadingHistory: boolean;
historyError: string | null;
onNearTop: () => void;
onSelect: (id: string) => void;
}) {
const navListRef = useRef<HTMLDivElement>(null);
const initializedScrollResetKeyRef = useRef<string | null>(null);
const restoreAfterPrependRef = useRef<{
scrollHeight: number;
scrollTop: number;
} | null>(null);
const collapsedItems = useMemo(
() => agentChatQuickNavCollapsedItems(items, activeItemId),
[activeItemId, items],
);
useLayoutEffect(() => {
const list = navListRef.current;
if (!list) {
restoreAfterPrependRef.current = null;
return;
}
if (initializedScrollResetKeyRef.current !== resetKey) {
restoreAfterPrependRef.current = null;
if (items.length === 0) {
return;
}
list.scrollTop = list.scrollHeight;
initializedScrollResetKeyRef.current = resetKey;
return;
}
const restore = restoreAfterPrependRef.current;
if (!restore) {
return;
}
list.scrollTop = list.scrollHeight - restore.scrollHeight + restore.scrollTop;
restoreAfterPrependRef.current = null;
}, [items.length, resetKey]);
const handleNavScroll = useCallback(() => {
const list = navListRef.current;
if (!list || isLoadingHistory || !hasMoreBefore) {
return;
}
if (
list.scrollTop <= AGENT_CHAT_STICKY_BOTTOM_THRESHOLD_PX &&
!restoreAfterPrependRef.current
) {
restoreAfterPrependRef.current = {
scrollHeight: list.scrollHeight,
scrollTop: list.scrollTop,
};
onNearTop();
}
}, [hasMoreBefore, isLoadingHistory, onNearTop]);
useEffect(() => {
if (!isLoadingHistory) {
restoreAfterPrependRef.current = null;
}
}, [isLoadingHistory]);
useEffect(() => {
const list = navListRef.current;
if (
!list ||
list.clientHeight <= 0 ||
isLoadingHistory ||
!hasMoreBefore ||
historyError ||
restoreAfterPrependRef.current
) {
return;
}
if (list.scrollHeight <= list.clientHeight) {
restoreAfterPrependRef.current = {
scrollHeight: list.scrollHeight,
scrollTop: list.scrollTop,
};
onNearTop();
}
}, [hasMoreBefore, historyError, isLoadingHistory, items.length, onNearTop]);
const [isQuickNavOpen, setIsQuickNavOpen] = useState(false);
useEffect(() => {
setIsQuickNavOpen(false);
}, [resetKey]);
const hasQuickNavContent =
items.length > 0 || hasMoreBefore || isLoadingHistory || Boolean(historyError);
const handleSelect = useCallback(
(id: string) => {
onSelect(id);
setIsQuickNavOpen(false);
},
[onSelect],
);
if (items.length === 0 && !hasMoreBefore && !isLoadingHistory && !historyError) {
return null;
}
const quickNavToggleLabel = isQuickNavOpen
? "Collapse quick navigation"
: "Open quick navigation";
return (
<nav
aria-label="User message quick navigation"
className="group pointer-events-none fixed top-1/2 right-0 z-50 h-[min(26rem,calc(100vh-1rem))] w-[min(17rem,calc(100vw-1rem))] -translate-y-1/2 md:pointer-events-auto md:right-4 md:flex md:w-auto md:items-center md:justify-end"
>
<button
type="button"
aria-label={quickNavToggleLabel}
aria-expanded={isQuickNavOpen}
title={quickNavToggleLabel}
onClick={() => setIsQuickNavOpen((open) => !open)}
className={cn(
"pointer-events-auto absolute top-1/2 z-10 flex h-14 w-9 -translate-y-1/2 items-center justify-center rounded-l-xl border border-r-0 border-border/70 bg-background/95 text-muted-foreground shadow-lg shadow-background/25 backdrop-blur-xl transition-all duration-200 hover:bg-muted/70 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none md:hidden",
isQuickNavOpen ? "left-0 -translate-x-full" : "right-0",
)}
>
{isQuickNavOpen ? (
<ChevronRightIcon aria-hidden="true" />
) : (
<ChevronLeftIcon aria-hidden="true" />
)}
</button>
<div
className={cn(
"pointer-events-auto absolute inset-y-0 right-0 flex h-full w-full overflow-hidden rounded-l-2xl border border-border/70 bg-background/95 shadow-xl shadow-background/25 backdrop-blur-xl transition-transform duration-200 ease-out md:static md:contents md:translate-x-0 md:transition-none",
isQuickNavOpen ? "translate-x-0" : "translate-x-full",
)}
>
{hasQuickNavContent ? (
<div
aria-hidden="true"
className="hidden h-full max-h-[calc(100vh-1rem)] w-10 flex-col items-end justify-center gap-2.5 rounded-full px-2 py-2.5 transition-opacity duration-150 md:flex md:group-hover:opacity-0 md:group-focus-within:opacity-0"
>
{collapsedItems.length > 0 ? (
collapsedItems.map((item) => (
<span
key={item.id}
className={cn(
"h-[3px] w-6 rounded-full bg-muted-foreground/45 transition-colors",
item.id === activeItemId && "bg-foreground",
)}
/>
))
) : (
<span className="h-[3px] w-6 rounded-full bg-muted-foreground/45" />
)}
</div>
) : null}
<div
onFocusCapture={() => setIsQuickNavOpen(true)}
className="min-w-0 flex-1 md:pointer-events-none md:absolute md:top-1/2 md:right-0 md:w-[min(17rem,calc(100vw-1rem))] md:-translate-y-1/2 md:overflow-hidden md:rounded-lg md:border md:border-border/70 md:bg-background md:opacity-0 md:shadow-lg md:shadow-background/20 md:transition-opacity md:duration-150 md:group-hover:pointer-events-auto md:group-hover:opacity-100 md:group-focus-within:pointer-events-auto md:group-focus-within:opacity-100"
>
<div
ref={navListRef}
onScroll={handleNavScroll}
className="flex h-full min-w-0 flex-col gap-1 overflow-y-auto p-1.5 md:h-auto md:max-h-[min(26rem,calc(100vh-1rem))]"
>
{isLoadingHistory ? (
<div className="flex min-h-9 items-center gap-2 rounded-md px-2.5 py-1.5 text-sm leading-5 text-muted-foreground">
<Spinner data-icon="inline-start" />
Loading older…
</div>
) : null}
{historyError ? (
<div className="rounded-md px-2.5 py-1.5 text-sm text-destructive">
{historyError}
</div>
) : null}
{items.map((item) => {
const active = item.id === activeItemId;
return (
<button
key={item.id}
type="button"
aria-current={active ? "location" : undefined}
title={item.label}
onClick={() => handleSelect(item.id)}
className={cn(
"min-h-8 w-full rounded-md px-2.5 py-1.5 text-left text-sm leading-5 text-foreground/90 transition-colors hover:bg-muted/70 focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none",
active && "bg-muted text-foreground",
)}
>
<span className="block truncate">{item.label}</span>
</button>
);
})}
</div>
</div>
</div>
</nav>
);
}
function AgentChatFoldedActivityGroup({
id,
sessionId,
bubbles,
outputBubble,
activeRuntimeStatusBubbleId,
latestReplyBubbleId,
onOpenWorkflowInspector,
open,
onOpenChange,
isFocused = true,
}: {
id: string;
sessionId?: string;
bubbles: AgentChatBubble[];
outputBubble: AgentChatBubble;
activeRuntimeStatusBubbleId?: string | null;
latestReplyBubbleId?: string | null;
onOpenWorkflowInspector?: (snapshot: WorkflowRunSnapshot) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
isFocused?: boolean;
}) {
const { toggle } = useCollapsibleState(false, open, onOpenChange);
const activityCount = bubbles.length;
const workedDurationLabel = formatAgentChatWorkedDuration(bubbles, outputBubble);
if (activityCount === 0) {
return null;
}
return (
<article
className={cn(
"w-full min-w-0 max-w-full text-sm leading-6 text-muted-foreground",
!isFocused && "select-none",
)}
>
<div className="min-w-0 max-w-full">
<div
data-agent-chat-fold-header="true"
className="min-w-0 max-w-full"
>
<AgentChatWorkedDivider
label={`Worked for ${workedDurationLabel}`}
open={open}
onToggle={isFocused ? toggle : undefined}
/>
</div>
{isFocused && open ? (
<div className="min-w-0 pt-2">
{bubbles.map((bubble) => (
<AgentChatBubbleItem
key={`${id}-${bubble.id}`}
sessionId={sessionId}
bubble={bubble}
activeRuntimeStatusBubbleId={activeRuntimeStatusBubbleId}
isFocused={isFocused}
isLatestReply={bubble.id === latestReplyBubbleId}
onOpenWorkflowInspector={onOpenWorkflowInspector}
compact
/>
))}
</div>
) : null}
</div>
</article>
);
}
function AgentChatBubbleItem({
sessionId,
bubble,
activeRuntimeStatusBubbleId,
isFocused = true,
isLatestReply = false,
onOpenWorkflowInspector,
compact = false,
}: {
sessionId?: string;
bubble: AgentChatBubble;
activeRuntimeStatusBubbleId?: string | null;
isFocused?: boolean;
isLatestReply?: boolean;
onOpenWorkflowInspector?: (snapshot: WorkflowRunSnapshot) => void;
compact?: boolean;
}) {
const isConversationMessage = agentChatBubbleIsConversationMessage(bubble);
const SessionActivityRender = agentChatSessionActivityRenderForBubble(bubble);
const useCanonicalSessionActivity = Boolean(SessionActivityRender);
const primaryBlocks = useCanonicalSessionActivity
? []
: agentChatDisplayBlocksForBubble(
bubble,
bubble.blocks.length > 0
? bubble.blocks
: isConversationMessage
? ([{ type: "text", text: bubble.title }] as AgentChatBlock[])
: [],
);
const visibleBlockLimit =
isConversationMessage && isFocused
? primaryBlocks.length
: isFocused
? 6
: 3;
const visibleBlocks = primaryBlocks.slice(0, visibleBlockLimit);
const content = (
<div
className={cn(
"w-full min-w-0 max-w-full overflow-hidden [overflow-wrap:anywhere]",
bubble.live || bubble.status === "running"
? "text-foreground"
: "text-foreground/95",
!isFocused && "select-none",
)}
>
<div className="flex min-w-0 max-w-full flex-col gap-2 text-sm leading-6 text-foreground">
{!isConversationMessage && !useCanonicalSessionActivity ? (
<AgentChatActivityHeader bubble={bubble} isFocused={isFocused} />
) : null}
{SessionActivityRender ? (
<AgentChatSessionActivityView
sessionId={sessionId}
bubbleId={bubble.id}
render={SessionActivityRender}
isActiveRuntimeStatus={bubble.id === activeRuntimeStatusBubbleId}
isLatestReply={isLatestReply}
onOpenWorkflowInspector={onOpenWorkflowInspector}
/>
) : (
<div className="flex min-w-0 max-w-full flex-col gap-2 text-foreground/90">
{visibleBlocks.map((block, index) => (
<AgentChatBlock
key={`${bubble.id}-block-${index}`}
block={block}
blockId={`${bubble.id}-block-${index}`}
isFocused={isFocused}
messageMode={isConversationMessage}
/>
))}
</div>
)}
</div>
</div>
);
if (compact) {
return <div className="min-w-0 max-w-full py-1.5">{content}</div>;
}
return (
<article
aria-label={bubble.title || "Activity"}
className={cn(
"w-full min-w-0 max-w-full overflow-x-clip border-l border-transparent px-1 py-1.5 [overflow-wrap:anywhere]",
bubble.status === "failed" || bubble.kind === "error"
? "border-destructive/45"
: "",
)}
>
{content}
</article>
);
}
function AgentChatWorkedDivider({
label,
compact = false,
open,
onToggle,
}: {
label: string;
compact?: boolean;
open?: boolean;
onToggle?: () => void;
}) {
const interactive = Boolean(onToggle);
const className = cn(
"group flex w-full min-w-0 items-center gap-1 border-b border-border/70 px-2 text-left text-sm text-muted-foreground transition-colors",
compact ? "h-8" : "h-10",
interactive && "cursor-pointer hover:text-foreground",
);
const content = (
<>
<span className="min-w-0 shrink-0 truncate">{label}</span>
{interactive ? (
<ChevronRightIcon
aria-hidden="true"
className={cn(
"size-4 shrink-0 transition-transform duration-150",
open && "rotate-90",
)}
/>
) : null}
</>
);
if (interactive) {
return (
<button
type="button"
aria-expanded={open}
onClick={onToggle}
className={className}
>
{content}
</button>
);
}
return <div className={className}>{content}</div>;
}
function AgentChatActivityHeader({
bubble,
isFocused,
}: {
bubble: AgentChatBubble;
isFocused: boolean;
}) {
const isRunning = bubble.live || bubble.status === "running";
const statusText = agentChatActivityStatusText(bubble.status, bubble.live);
const subtitle = agentChatActivitySubtitle(bubble);
const icon = agentChatActivityIconForBubble(bubble);
return (
<div
className={cn(
AGENT_CHAT_ACTIVITY_ROW_CLASS,
"text-foreground",
!isFocused && "opacity-90",
)}
>
<AgentChatActivityMarker
icon={icon}
tone={icon === "error" ? "error" : "default"}
className={cn(
agentChatActivityIconClass(bubble),
isRunning && "motion-safe:animate-pulse",
!isFocused && "text-xs",
)}
/>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<p
className={cn(
"min-w-0 break-words text-sm font-semibold leading-6 text-foreground [overflow-wrap:anywhere]",
!isFocused && "text-xs leading-5",
)}
>
{bubble.title}
</p>
{isRunning || bubble.status === "failed" ? (
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 text-[0.62rem] font-medium leading-none",
agentChatActivityStatusClass(bubble.status, bubble.live),
)}
>
{isRunning ? <Spinner className="size-2.5" /> : null}
{statusText}
</span>
) : null}
</div>
{subtitle && isFocused ? (
<p className="break-words text-xs leading-5 text-muted-foreground/80">
{subtitle}
</p>
) : null}
</div>
</div>
);
}
function AgentChatSessionActivityView({
sessionId,
bubbleId,
render,
isActiveRuntimeStatus = false,
isLatestReply = false,
onOpenWorkflowInspector,
}: AgentChatSessionActivityViewProps) {
if (render.kind === "text") {
if (render.icon === "user") {
return (
<AgentChatPromptCell
id={bubbleId}
title={render.title}
bodyLines={render.bodyLines}
fullBody={render.fullBody}
imageAttachments={render.imageAttachments}
markdown={false}
/>
);
}
return (
<AgentChatActivityTextCell
id={bubbleId}
icon={render.icon}
title={render.title}
bodyLines={render.bodyLines}
fullBody={render.fullBody}
imageAttachments={render.imageAttachments}
bodyLimit={render.bodyLimit}
tone={render.tone}
preserveSoftBreaks={render.preserveSoftBreaks}
/>
);
}
if (render.kind === "thinking") {
return (
<AgentChatThinkingCollapsibleCell
content={render.content}
bodyLimit={render.bodyLimit}
/>
);
}
if (render.kind === "runtimeStatus") {
return (
<AgentChatRuntimeStatusCell
agentExpressionSlotKey={
isActiveRuntimeStatus
? agentChatExpressionSlotKey("runtime", bubbleId)
: null
}
title={render.title}
detail={render.detail}
startedAtMs={render.startedAtMs}
reducedMotion={render.reducedMotion}
/>
);
}
if (render.kind === "workflow") {
return (
<AgentChatWorkflowActivityCell
sessionId={sessionId}
render={render}
onOpenInspector={onOpenWorkflowInspector}
/>
);
}
if (render.kind === "browser") {
return (
<AgentChatActivityTextCell
id={bubbleId}
icon={render.icon}
title={render.title}
bodyLines={render.detailLines}
bodyLimit={render.detailLimit}
tone="muted"
/>
);
}
if (render.kind === "plan") {
return render.steps.length > 0 ? (
<AgentChatPlanActivityPanel
icon={render.icon}
title={render.title}
steps={render.steps}
/>
) : null;
}
if (render.kind === "primitive") {
return (
<AgentChatStatusLineCell
icon={render.icon}
label={render.title}
value={render.primitiveId}
valueClassName="font-mono break-all"
/>
);
}
if (render.kind === "exec") {
return (
<AgentChatCommandExecutionPanel
mode={render.running ? "running" : "completed"}
icon={render.icon}
title={render.title}
outputLines={render.outputLines}
exitCode={render.exitCode}
/>
);
}
if (render.kind === "explored") {
return (
<AgentChatExploredActivityPanel
icon={render.icon}
title={render.title}
calls={render.calls}
/>
);
}
if (render.kind === "patch") {
return (
<AgentChatPatchActivityPanel
icon={render.icon}
title={render.title}
files={render.files}
/>
);
}
if (render.kind === "messageActivity") {
return (
<AgentChatMessageActivityLine
id={bubbleId}
icon={render.icon}
title={render.title}
detailLines={render.detailLines}
messageLines={render.messageLines}
detailLimit={render.detailLimit}
messageLimit={render.messageLimit}
/>
);
}
return (
<AgentChatReplyActivityLine
id={bubbleId}
title={render.title}
messageLines={render.messageLines}
disposition={render.disposition}
subject={render.subject}
isLatestReply={isLatestReply}
/>
);
}
function AgentChatWorkflowActivityCell({
sessionId,
render,
onOpenInspector,
}: {
sessionId?: string;
render: Extract<AgentChatSessionActivityRender, { kind: "workflow" }>;
onOpenInspector?: (snapshot: WorkflowRunSnapshot) => void;
}) {
const workflowSnapshot = render.snapshot;
const hasInspector = Boolean(workflowSnapshot && sessionId && onOpenInspector);
return (
<>
{workflowSnapshot ? (
<WorkflowInlinePreview
snapshot={workflowSnapshot}
onOpenPreview={
hasInspector
? () => {
onOpenInspector?.(workflowSnapshot);
}
: undefined
}
/>
) : (
<div className="flex min-w-0 max-w-full flex-col gap-1 text-sm leading-6 text-foreground/90 [overflow-wrap:anywhere]">
<WorkflowActivityHeading
status={render.status}
workflowId={render.workflowId}
/>
{render.message ? (
<p className="min-w-0 break-words pl-8 pr-2 text-xs leading-5 text-muted-foreground sm:pl-10 sm:pr-3">
{render.message}
</p>
) : null}
</div>
)}
</>
);
}
function workflowSnapshotFromActivityBubble(
bubble: AgentChatBubble,
): WorkflowRunSnapshot | null {
const workflow = agentChatSessionActivityPayload(bubble.activityEvent, "Workflow");
return workflow ? asWorkflowRunSnapshot(workflow.snapshot) : null;
}
function WorkflowInlinePreview({
snapshot,
onOpenPreview,
}: {
snapshot: WorkflowRunSnapshot;
onOpenPreview?: () => void;
}) {
const graph = useMemo(() => workflowInspectorGraph(snapshot), [snapshot]);
return (
<div className="flex min-w-0 max-w-full flex-col gap-1 text-sm leading-6 text-foreground/90 [overflow-wrap:anywhere]">
<WorkflowActivityHeading
status={snapshot.status}
workflowId={snapshot.workflow_id}
onOpenPreview={onOpenPreview}
/>
{snapshot.workers.length === 0 ? (
<p className="min-w-0 break-words pl-8 pr-2 text-xs leading-5 text-muted-foreground sm:pl-10 sm:pr-3">
Waiting for the first agent.
</p>
) : (
<div className="min-w-0 pl-8 pr-2 sm:pl-10 sm:pr-3">
<div className="h-64 min-w-0 bg-muted/15">
<WorkflowInspectorGraphCanvas
nodes={graph.nodes}
edges={graph.edges}
interactive={false}
/>
</div>
</div>
)}
</div>
);
}
function WorkflowActivityHeading({
status,
workflowId,
onOpenPreview,
}: {
status: WorkflowNodeStatus | string;
workflowId: string;
onOpenPreview?: () => void;
}) {
const normalizedStatus = status.toLowerCase();
const isError = normalizedStatus === "failed" || normalizedStatus === "interrupted";
return (
<div
className={cn(
AGENT_CHAT_ACTIVITY_ROW_CLASS,
"items-center text-sm leading-6 text-foreground/90 [overflow-wrap:anywhere]",
)}
>
<AgentChatActivityMarker
icon="activity"
tone={isError ? "error" : "default"}
className={cn(
normalizedStatus === "running" && "text-primary motion-safe:animate-pulse",
)}
/>
<div className="flex min-w-0 items-center gap-x-2">
<p className="min-w-0 break-words font-semibold text-foreground">
<span className={workflowStatusTextClass(status)}>
{workflowActivityVerb(status)} Workflow
</span>{" "}
<span className="font-mono text-foreground/90">{workflowId}</span>
</p>
{onOpenPreview ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="ml-auto shrink-0"
aria-label="Open workflow preview"
title="Open workflow preview"
onClick={onOpenPreview}
>
<Maximize2Icon aria-hidden="true" />
</Button>
) : null}
</div>
</div>
);
}
type WorkflowInspectorNodeData = {
actorId: string;
role: string;
label: string;
detail: string;
status: WorkflowNodeStatus;
agentRunTimeMs: number;
attemptCount: number;
};
type WorkflowInspectorActor = {
actorId: string;
role: string;
model: string;
status: WorkflowNodeStatus;
agentRunTimeMs: number;
attemptCount: number;
firstStartedAtMs: number;
};
type WorkflowInspectorActorTransition = {
sourceActorId: string;
targetActorId: string;
kind: WorkflowTransitionKind;
count: number;
};
type WorkflowInspectorGraphNode = FlowNode<WorkflowInspectorNodeData>;
const WORKFLOW_INSPECTOR_NODE_TYPES: NodeTypes = {
workflowInspector: WorkflowInspectorGraphNodeView,
};
function WorkflowInspectorDialog({
sessionId,
snapshot,
open,
onOpenChange,
}: {
sessionId: string;
snapshot: WorkflowRunSnapshot;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [selectedActorId, setSelectedActorId] = useState<string | null>(null);
const selectedActorWorkers = useMemo(
() => selectedActorId
? workflowInspectorWorkersForActor(snapshot.workers, selectedActorId)
: [],
[selectedActorId, snapshot.workers],
);
const selectedWorker = selectedActorWorkers.at(-1) ?? null;
const graph = useMemo(() => workflowInspectorGraph(snapshot), [snapshot]);
const handleSelectActor = useCallback((actorId: string) => {
setSelectedActorId(actorId);
}, []);
useEffect(() => {
if (!open) {
setSelectedActorId(null);
}
}, [open]);
useEffect(() => {
if (selectedActorId && !snapshot.workers.some(
(worker) => workflowWorkerActorId(worker) === selectedActorId,
)) {
setSelectedActorId(null);
}
}, [selectedActorId, snapshot.workers]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex h-[min(94vh,60rem)] w-[min(96vw,88rem)] max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none">
<DialogHeader className="border-b px-5 py-4 pr-12">
<div className="flex min-w-0 items-center gap-3">
{selectedActorId ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Back to workflow preview"
title="Back to workflow preview"
onClick={() => setSelectedActorId(null)}
>
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
</Button>
) : null}
<div className="min-w-0">
<DialogTitle className="flex min-w-0 flex-wrap items-center gap-2">
<span>{selectedActorId ? "Agent activity" : "Workflow preview"}</span>
<Badge variant="outline" className={workflowStatusTextClass(snapshot.status)}>
{workflowStatusLabel(snapshot.status)}
</Badge>
</DialogTitle>
<DialogDescription className="truncate font-mono text-xs">
{selectedWorker
? `${selectedWorker.role} · ${selectedWorker.model} · ${selectedActorWorkers.length} ${
selectedActorWorkers.length === 1 ? "attempt" : "attempts"
}`
: `${snapshot.workflow_id} · ${snapshot.run_id}`}
</DialogDescription>
</div>
</div>
</DialogHeader>
{selectedWorker ? (
<WorkflowInspectorAgentActivity
sessionId={sessionId}
runId={snapshot.run_id}
agent={selectedWorker}
actorWorkers={selectedActorWorkers}
/>
) : (
<WorkflowInspectorGraph
snapshot={snapshot}
nodes={graph.nodes}
edges={graph.edges}
onSelectActor={handleSelectActor}
/>
)}
</DialogContent>
</Dialog>
);
}
function WorkflowInspectorGraph({
snapshot,
nodes,
edges,
onSelectActor,
}: {
snapshot: WorkflowRunSnapshot;
nodes: WorkflowInspectorGraphNode[];
edges: Edge[];
onSelectActor: (actorId: string) => void;
}) {
const actorCount = nodes.length;
const runCount = snapshot.workers.length;
const agentRunTimeMs = snapshot.workers.reduce(
(elapsed, worker) => elapsed + workflowWorkerAgentRunTime(worker),
0,
);
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b px-5 py-3 text-xs text-muted-foreground">
<span>{actorCount} {actorCount === 1 ? "agent" : "agents"}</span>
<span>{runCount} {runCount === 1 ? "run" : "runs"}</span>
<span>Worked for {formatWorkflowAgentRunTime(agentRunTimeMs)} total</span>
<span className="ml-auto">Select an agent to inspect its activity.</span>
</div>
{snapshot.workers.length === 0 ? (
<Empty className="rounded-none border-0">
<EmptyHeader>
<EmptyMedia variant="icon">
<CommandIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Waiting for the first agent</EmptyTitle>
<EmptyDescription>
Agents appear dynamically as the workflow starts them.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className="min-h-0 flex-1 bg-muted/15">
<WorkflowInspectorGraphCanvas
nodes={nodes}
edges={edges}
interactive
onSelectActor={onSelectActor}
/>
</div>
)}
</div>
);
}
function WorkflowInspectorGraphCanvas({
nodes,
edges,
interactive,
onSelectActor,
}: {
nodes: WorkflowInspectorGraphNode[];
edges: Edge[];
interactive: boolean;
onSelectActor?: (actorId: string) => void;
}) {
return (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={WORKFLOW_INSPECTOR_NODE_TYPES}
fitView
fitViewOptions={{
padding: interactive ? 0.2 : 0.38,
maxZoom: interactive ? 1.15 : 1,
}}
minZoom={interactive ? 0.2 : 0.5}
maxZoom={interactive ? 1.6 : 1}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={interactive}
nodesFocusable={interactive}
edgesFocusable={interactive}
panOnDrag={interactive}
panOnScroll={interactive}
zoomOnScroll={interactive}
zoomOnPinch={interactive}
zoomOnDoubleClick={interactive}
preventScrolling={!interactive}
onNodeClick={
interactive && onSelectActor
? (_, node) => {
onSelectActor(node.data.actorId);
}
: undefined
}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1} />
{interactive ? (
<>
<MiniMap
pannable
zoomable
nodeColor={(node) => workflowStatusGraphColor(String(node.data.status))}
maskColor="color-mix(in oklab, var(--background) 72%, transparent)"
/>
<Controls showInteractive={false} />
</>
) : null}
</ReactFlow>
);
}
function WorkflowInspectorGraphNodeView({ data }: NodeProps<WorkflowInspectorGraphNode>) {
return (
<div
className={cn(
"min-w-48 rounded-xl border bg-background px-4 py-3 text-left shadow-sm",
data.status === "running" && "border-primary/40 shadow-primary/10",
(data.status === "failed" || data.status === "interrupted") && "border-destructive/45",
)}
>
<Handle id="top-target" type="target" position={Position.Top} className="opacity-0" />
<Handle id="right-target" type="target" position={Position.Right} className="opacity-0" />
<div className="flex items-center gap-2">
<span className={cn("size-2 rounded-full", workflowStatusDotClass(data.status))} />
<span className="truncate text-sm font-medium text-foreground">{data.label}</span>
{data.status === "running" ? <Spinner className="ml-auto size-3" /> : null}
</div>
<p className="mt-1 truncate font-mono text-[0.68rem] text-muted-foreground">
{data.detail}
</p>
<p className="mt-2 text-[0.68rem] text-muted-foreground/80">
{data.attemptCount} {data.attemptCount === 1 ? "attempt" : "attempts"} · Worked for {formatWorkflowAgentRunTime(data.agentRunTimeMs)}
</p>
<Handle id="bottom-source" type="source" position={Position.Bottom} className="opacity-0" />
<Handle id="right-source" type="source" position={Position.Right} className="opacity-0" />
</div>
);
}
function WorkflowInspectorAgentActivity({
sessionId,
runId,
agent,
actorWorkers,
}: {
sessionId: string;
runId: string;
agent: WorkflowWorkerSnapshot;
actorWorkers: WorkflowWorkerSnapshot[];
}) {
const isMockData = useContext(AgentChatMockDataContext);
const visibleWorkers = useMemo(() => {
const selectedIndex = actorWorkers.findIndex(
(worker) => worker.worker_id === agent.worker_id,
);
return selectedIndex === -1
? [agent]
: actorWorkers.slice(0, selectedIndex + 1);
}, [actorWorkers, agent]);
const visibleWorkerIds = visibleWorkers.map((worker) => worker.worker_id).join(":");
const visibleActivityRevision = visibleWorkers.reduce(
(revision, worker) => revision + (worker.activity_revision ?? 0),
0,
);
const [pages, setPages] = useState<Record<string, WorkflowWorkerActivityPage>>({});
const [isLoading, setIsLoading] = useState(false);
const [isLoadingOlderWorkerId, setIsLoadingOlderWorkerId] = useState<string | null>(null);
const [activityError, setActivityError] = useState<string | null>(null);
const requestSequenceRef = useRef(0);
const attempts = useMemo(
() => visibleWorkers.map((worker, index) => {
const page = pages[worker.worker_id];
const legacyActivity = worker.activity ?? [];
const persistedActivities = page?.items.map((item) => item.event) ?? legacyActivity;
const activities = workflowWorkerActivitiesWithInput(persistedActivities, worker.input);
return {
worker,
attempt: index + 1,
page,
activityCount: Math.max(
page?.activity_count ?? 0,
worker.activity_count ?? 0,
activities.length,
),
bubbles: agentChatBubblesFromSessionActivities(
activities,
`workflow-${worker.worker_id}`,
),
};
}),
[pages, visibleWorkers],
);
const activityCount = attempts.reduce(
(count, attempt) => count + attempt.activityCount,
0,
);
const activityContentKey = useMemo(
() => ({ attempts, error: agent.error, activityError }),
[activityError, agent.error, attempts],
);
const viewportRef = useRef<HTMLDivElement | null>(null);
const activityScroll = useActivityFollowBottom<HTMLDivElement>(
viewportRef,
activityContentKey,
`${runId}:${agent.worker_id}`,
);
useEffect(() => {
const requestSequence = requestSequenceRef.current + 1;
requestSequenceRef.current = requestSequence;
setActivityError(null);
setIsLoadingOlderWorkerId(null);
if (isMockData) {
setPages({});
setIsLoading(false);
return;
}
const controller = new AbortController();
setIsLoading(true);
void Promise.all(
visibleWorkers.map((worker) => fetchWorkflowWorkerActivity({
sessionId,
runId,
workerId: worker.worker_id,
signal: controller.signal,
})),
)
.then((nextPages) => {
if (requestSequenceRef.current === requestSequence) {
setPages(Object.fromEntries(nextPages.map((page) => [page.worker_id, page])));
}
})
.catch((error) => {
if (!controller.signal.aborted && requestSequenceRef.current === requestSequence) {
setActivityError(error instanceof Error ? error.message : String(error));
}
})
.finally(() => {
if (!controller.signal.aborted && requestSequenceRef.current === requestSequence) {
setIsLoading(false);
}
});
return () => controller.abort();
}, [isMockData, runId, sessionId, visibleActivityRevision, visibleWorkerIds]);
const loadOlder = useCallback(async () => {
const oldestAttempt = attempts.find(
({ page }) => page?.has_more_before && page.oldest_cursor !== null,
);
if (!oldestAttempt?.page || isLoadingOlderWorkerId) {
return;
}
const requestSequence = requestSequenceRef.current;
activityScroll.captureBeforePrepend();
setIsLoadingOlderWorkerId(oldestAttempt.worker.worker_id);
setActivityError(null);
try {
const older = await fetchWorkflowWorkerActivity({
sessionId,
runId,
workerId: oldestAttempt.worker.worker_id,
before: oldestAttempt.page.oldest_cursor ?? undefined,
});
if (requestSequenceRef.current !== requestSequence) {
return;
}
setPages((current) => ({
...current,
[older.worker_id]: current[older.worker_id]
? mergeWorkflowWorkerActivityPages(older, current[older.worker_id])
: older,
}));
} catch (error) {
if (requestSequenceRef.current === requestSequence) {
setActivityError(error instanceof Error ? error.message : String(error));
}
} finally {
if (requestSequenceRef.current === requestSequence) {
setIsLoadingOlderWorkerId(null);
}
}
}, [activityScroll.captureBeforePrepend, attempts, isLoadingOlderWorkerId, runId, sessionId]);
const totalRunTimeMs = visibleWorkers.reduce(
(elapsed, worker) => elapsed + workflowWorkerAgentRunTime(worker),
0,
);
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-wrap items-center gap-2 border-b px-5 py-3">
<Badge variant="secondary">{agent.role}</Badge>
<span className="font-mono text-xs text-muted-foreground">{agent.model}</span>
<Badge variant="outline" className={workflowStatusTextClass(agent.status)}>
{workflowStatusLabel(agent.status)}
</Badge>
<span className="text-xs text-muted-foreground">
Worked for {formatWorkflowAgentRunTime(totalRunTimeMs)} across {visibleWorkers.length} {visibleWorkers.length === 1 ? "attempt" : "attempts"}
</span>
<span className="ml-auto text-xs text-muted-foreground">
{activityCount} {activityCount === 1 ? "activity" : "activities"}
</span>
</div>
<div
ref={viewportRef}
aria-label="Agent activity"
onScroll={(event) => {
activityScroll.handleScroll(event);
if (event.currentTarget.scrollTop <= AGENT_CHAT_STICKY_BOTTOM_THRESHOLD_PX) {
void loadOlder();
}
}}
className="min-h-0 flex-1 overflow-y-auto"
>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-1 px-5 py-5">
{isLoading || isLoadingOlderWorkerId ? (
<div className="flex items-center justify-center gap-2 py-3 text-xs text-muted-foreground">
<Spinner className="size-3" />
{isLoading ? "Loading agent history…" : "Loading earlier activity…"}
</div>
) : null}
{activityError ? (
<Alert variant="destructive">
<AlertTriangleIcon aria-hidden="true" />
<AlertDescription>{activityError}</AlertDescription>
</Alert>
) : null}
{attempts.some(({ bubbles }) => bubbles.length > 0) ? (
attempts.map(({ worker, attempt, bubbles }) => (
<Fragment key={worker.worker_id}>
<WorkflowInspectorAttemptDivider
attempt={attempt}
worker={worker}
/>
{bubbles.map((bubble) => (
<AgentChatBubbleItem
key={`${worker.worker_id}-${bubble.id}`}
bubble={bubble}
isFocused
compact
/>
))}
</Fragment>
))
) : !isLoading ? (
<Empty className="min-h-64 border-0">
<EmptyHeader>
<EmptyMedia variant="icon">
<CommandIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>No agent activity yet</EmptyTitle>
<EmptyDescription>
Inputs, thinking, assistant turns, and tool activity will appear here as the agent runs.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : null}
{agent.error ? (
<Alert variant="destructive">
<AlertTriangleIcon aria-hidden="true" />
<AlertDescription>{agent.error}</AlertDescription>
</Alert>
) : null}
</div>
</div>
</div>
);
}
function WorkflowInspectorAttemptDivider({
attempt,
worker,
}: {
attempt: number;
worker: WorkflowWorkerSnapshot;
}) {
return (
<div className="flex min-w-0 items-center gap-3 py-2" aria-label={`Attempt ${attempt}`}>
<Separator className="min-w-6 flex-1" />
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
<span className={cn("size-2 rounded-full", workflowStatusDotClass(worker.status))} />
<span className="font-medium text-foreground">Attempt {attempt}</span>
<span>{formatWorkflowWorkerStartedAt(worker.started_at_ms)}</span>
</div>
<Separator className="min-w-6 flex-1" />
</div>
);
}
function workflowWorkerActivitiesWithInput(
activities: SessionActivityEvent[],
input: unknown,
) {
if (activities.some((activity) => Boolean(agentChatSessionActivityPayload(activity, "User")))) {
return activities;
}
return [{ User: { content: workflowWorkerInputText(input) } }, ...activities];
}
function workflowWorkerInputText(input: unknown) {
if (typeof input === "string") {
return input;
}
try {
return JSON.stringify(input, null, 2) ?? String(input);
} catch {
return String(input);
}
}
function mergeWorkflowWorkerActivityPages(
older: WorkflowWorkerActivityPage,
newer: WorkflowWorkerActivityPage,
): WorkflowWorkerActivityPage {
const items = new Map<number, WorkflowWorkerActivityPage["items"][number]>();
for (const item of [...older.items, ...newer.items]) {
items.set(item.cursor, item);
}
const merged = [...items.values()].sort((left, right) => left.cursor - right.cursor);
return {
...newer,
items: merged,
oldest_cursor: merged[0]?.cursor ?? null,
newest_cursor: merged.at(-1)?.cursor ?? null,
has_more_before: older.has_more_before,
has_more_after: newer.has_more_after,
activity_count: Math.max(older.activity_count, newer.activity_count),
revision: Math.max(older.revision, newer.revision),
};
}
function workflowInspectorGraph(snapshot: WorkflowRunSnapshot): {
nodes: WorkflowInspectorGraphNode[];
edges: Edge[];
} {
const graph = new graphlib.Graph().setDefaultEdgeLabel(() => ({}));
graph.setGraph({ rankdir: "TB", nodesep: 56, ranksep: 82, marginx: 24, marginy: 24 });
const workersById = new Map(
snapshot.workers.map((worker) => [worker.worker_id, worker] as const),
);
const actors = workflowInspectorActors(snapshot).sort(
(left, right) =>
left.firstStartedAtMs - right.firstStartedAtMs || left.actorId.localeCompare(right.actorId),
);
const roleActorCounts = new Map<string, number>();
for (const actor of actors) {
roleActorCounts.set(actor.role, (roleActorCounts.get(actor.role) ?? 0) + 1);
}
const roleActorIndices = new Map<string, number>();
const actorLabels = new Map<string, string>();
for (const actor of actors) {
const nextIndex = (roleActorIndices.get(actor.role) ?? 0) + 1;
roleActorIndices.set(actor.role, nextIndex);
actorLabels.set(
actor.actorId,
(roleActorCounts.get(actor.role) ?? 0) > 1 ? `${actor.role} #${nextIndex}` : actor.role,
);
}
const actorOrder = new Map(
actors.map((actor, index) => [actor.actorId, index] as const),
);
const graphEdges = workflowInspectorActorTransitions(snapshot, workersById).map(
(transition, index) => {
const sourceOrder = actorOrder.get(transition.sourceActorId);
const targetOrder = actorOrder.get(transition.targetActorId);
const isLoopback =
sourceOrder !== undefined &&
targetOrder !== undefined &&
sourceOrder >= targetOrder;
return {
isLoopback,
edge: workflowInspectorEdge(
transition.sourceActorId,
transition.targetActorId,
`${transition.kind}:${transition.sourceActorId}:${transition.targetActorId}:${index}`,
workflowInspectorActorTransitionLabel(transition),
isLoopback,
),
};
},
);
const nodes: WorkflowInspectorGraphNode[] = actors.map((actor) => ({
id: actor.actorId,
type: "workflowInspector",
position: { x: 0, y: 0 },
data: {
actorId: actor.actorId,
role: actor.role,
label: actorLabels.get(actor.actorId) ?? actor.role,
detail: actor.model,
status: actor.status,
agentRunTimeMs: actor.agentRunTimeMs,
attemptCount: actor.attemptCount,
},
}));
let layoutEdgeCount = 0;
for (const { edge, isLoopback } of graphEdges) {
if (isLoopback || edge.source === edge.target) {
continue;
}
graph.setEdge(edge.source, edge.target);
layoutEdgeCount += 1;
}
if (layoutEdgeCount === 0) {
for (let index = 1; index < nodes.length; index += 1) {
graph.setEdge(nodes[index - 1].id, nodes[index].id);
}
}
for (const node of nodes) {
graph.setNode(node.id, { width: 224, height: 88 });
}
layoutDagreGraph(graph);
return {
nodes: nodes.map((node) => {
const position = graph.node(node.id) as {
x: number;
y: number;
width: number;
height: number;
};
return {
...node,
position: {
x: position.x - position.width / 2,
y: position.y - position.height / 2,
},
};
}),
edges: graphEdges.map(({ edge }) => edge),
};
}
function workflowInspectorActors(
snapshot: WorkflowRunSnapshot,
): WorkflowInspectorActor[] {
const workersByActor = new Map<string, WorkflowWorkerSnapshot[]>();
for (const worker of snapshot.workers) {
const actorId = workflowWorkerActorId(worker);
const workers = workersByActor.get(actorId);
if (workers) {
workers.push(worker);
} else {
workersByActor.set(actorId, [worker]);
}
}
return [...workersByActor.entries()].map(([actorId, workers]) => {
const latestWorker = workers.reduce((latest, worker) =>
worker.started_at_ms >= latest.started_at_ms ? worker : latest,
);
return {
actorId,
role: workflowWorkerRole(latestWorker),
model: latestWorker.model,
status: workflowInspectorAgentStatus(workers),
agentRunTimeMs: workers.reduce(
(elapsed, worker) => elapsed + workflowWorkerAgentRunTime(worker),
0,
),
attemptCount: workers.length,
firstStartedAtMs: workers.reduce(
(firstStartedAtMs, worker) => Math.min(firstStartedAtMs, worker.started_at_ms),
Number.POSITIVE_INFINITY,
),
};
});
}
function workflowInspectorAgentStatus(
workers: WorkflowWorkerSnapshot[],
): WorkflowNodeStatus {
const statuses = workers.map((worker) => worker.status.toLowerCase());
if (statuses.includes("running")) {
return "running";
}
if (statuses.includes("failed")) {
return "failed";
}
if (statuses.includes("interrupted")) {
return "interrupted";
}
if (statuses.includes("pending")) {
return "pending";
}
return "completed";
}
function workflowWorkerRole(worker: WorkflowWorkerSnapshot) {
return worker.role.trim() || "agent";
}
function workflowWorkerActorId(worker: WorkflowWorkerSnapshot) {
return worker.actor_id?.trim() || worker.worker_id;
}
function workflowInspectorWorkersForActor(
workers: WorkflowWorkerSnapshot[],
actorId: string,
) {
return workers
.filter((worker) => workflowWorkerActorId(worker) === actorId)
.sort(
(left, right) =>
left.started_at_ms - right.started_at_ms || left.worker_id.localeCompare(right.worker_id),
);
}
function workflowWorkerAgentRunTime(worker: WorkflowWorkerSnapshot) {
return Math.max(worker.agent_run_time_ms ?? 0, 0);
}
function workflowInspectorActorTransitions(
snapshot: WorkflowRunSnapshot,
workersById: Map<string, WorkflowWorkerSnapshot>,
): WorkflowInspectorActorTransition[] {
const transitionsByActor = new Map<string, WorkflowInspectorActorTransition>();
for (const transition of workflowInspectorTransitions(snapshot, workersById)) {
const sourceWorker = workersById.get(transition.source_worker_id);
const targetWorker = workersById.get(transition.target_worker_id);
if (!sourceWorker || !targetWorker) {
continue;
}
const sourceActorId = workflowWorkerActorId(sourceWorker);
const targetActorId = workflowWorkerActorId(targetWorker);
const key = `${transition.kind}:${sourceActorId}:${targetActorId}`;
const existing = transitionsByActor.get(key);
if (existing) {
existing.count += 1;
continue;
}
transitionsByActor.set(key, {
sourceActorId,
targetActorId,
kind: transition.kind,
count: 1,
});
}
return [...transitionsByActor.values()];
}
function workflowInspectorActorTransitionLabel(
transition: WorkflowInspectorActorTransition,
) {
return transition.count > 1
? `${transition.kind} (${transition.count})`
: transition.kind;
}
function workflowInspectorTransitions(
snapshot: WorkflowRunSnapshot,
workersById: Map<string, WorkflowWorkerSnapshot>,
): WorkflowTransitionSnapshot[] {
const directTransitions = snapshot.transitions.filter(
(transition) =>
workersById.has(transition.source_worker_id)
&& workersById.has(transition.target_worker_id)
&& transition.source_worker_id !== transition.target_worker_id,
);
if (directTransitions.length > 0) {
return directTransitions;
}
const transitions: WorkflowTransitionSnapshot[] = [];
let previousWorkerIds: string[] | null = null;
for (const group of [...snapshot.await_groups].sort((left, right) => left.sequence - right.sequence)) {
const workerIds = [...new Set(group.worker_ids)].filter((workerId) => workersById.has(workerId));
if (previousWorkerIds && workerIds.length > 0) {
for (const sourceWorkerId of previousWorkerIds) {
for (const targetWorkerId of workerIds) {
if (sourceWorkerId !== targetWorkerId) {
transitions.push({
source_worker_id: sourceWorkerId,
target_worker_id: targetWorkerId,
kind: "await",
});
}
}
}
}
if (workerIds.length > 0) {
previousWorkerIds = workerIds;
}
}
return transitions;
}
function workflowInspectorEdge(
source: string,
target: string,
id: string,
label?: string,
loopback = false,
): Edge {
return {
id,
source,
target,
sourceHandle: loopback ? "right-source" : "bottom-source",
targetHandle: loopback ? "right-target" : "top-target",
type: "smoothstep",
label,
markerEnd: { type: MarkerType.ArrowClosed },
style: { stroke: "var(--border)", strokeWidth: 1.5 },
};
}
function workflowActivityVerb(status: WorkflowNodeStatus | string) {
const normalized = status.toLowerCase();
if (normalized === "completed") {
return "Ran";
}
if (normalized === "failed") {
return "Failed";
}
if (normalized === "interrupted") {
return "Interrupted";
}
if (normalized === "pending") {
return "Queued";
}
return "Running";
}
function workflowStatusLabel(status: WorkflowNodeStatus | string) {
const normalized = status.toLowerCase();
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
}
function workflowStatusTextClass(status: WorkflowNodeStatus | string) {
const normalized = status.toLowerCase();
return cn(
normalized === "running" && "text-primary",
normalized === "completed" && "text-foreground",
(normalized === "failed" || normalized === "interrupted") && "text-destructive",
);
}
function workflowStatusDotClass(status: WorkflowNodeStatus | string) {
const normalized = status.toLowerCase();
return cn(
normalized === "pending" && "bg-muted-foreground/45",
normalized === "running" && "bg-primary motion-safe:animate-pulse",
normalized === "completed" && "bg-foreground",
(normalized === "failed" || normalized === "interrupted") && "bg-destructive",
);
}
function workflowStatusGraphColor(status: WorkflowNodeStatus | string) {
const normalized = status.toLowerCase();
if (normalized === "failed" || normalized === "interrupted") {
return "var(--destructive)";
}
if (normalized === "running") {
return "var(--primary)";
}
return "var(--foreground)";
}
function formatWorkflowAgentRunTime(agentRunTimeMs: number) {
const elapsed = Math.max(0, agentRunTimeMs);
if (elapsed < 1_000) {
return `${elapsed}ms`;
}
if (elapsed < 60_000) {
return `${(elapsed / 1_000).toFixed(elapsed < 10_000 ? 1 : 0)}s`;
}
const minutes = Math.floor(elapsed / 60_000);
const seconds = Math.floor((elapsed % 60_000) / 1_000);
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`;
}
function formatWorkflowWorkerStartedAt(startedAtMs: number) {
if (!Number.isFinite(startedAtMs) || startedAtMs <= 0) {
return "Start time unavailable";
}
return new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(startedAtMs);
}
const AGENT_CHAT_ACTIVITY_ROW_CLASS =
"grid min-w-0 grid-cols-[0.75rem_minmax(0,1fr)] items-start gap-x-3 px-2 sm:gap-x-[16px] sm:px-3";
const AGENT_CHAT_ACTIVITY_DETAIL_ROWS_CLASS =
"grid min-w-0 grid-cols-[1.5rem_minmax(0,1fr)] px-2 text-sm leading-6 sm:grid-cols-[1.75rem_minmax(0,1fr)] sm:px-3";
function AgentChatActivityMarker({
icon,
tone = "default",
className,
}: {
icon: AgentChatActivityMarkerKind;
tone?: "default" | "error";
className?: string;
}) {
const marker = icon === "error" || tone === "error" ? "■" : "•";
return (
<span
aria-hidden="true"
className={cn(
"inline-flex h-6 w-3 shrink-0 items-center justify-start font-mono text-sm font-semibold leading-none text-muted-foreground",
tone === "error" && "text-destructive",
className,
)}
>
{marker}
</span>
);
}
function AgentChatExpressionSlot({
slotKey,
status,
className,
}: {
slotKey: string;
status: AgentExpressionStatus;
className?: string;
}) {
const transitionContext = useContext(AgentChatExpressionTransitionContext);
const hidden = transitionContext?.hiddenSlotKey === slotKey;
const registerSlot = transitionContext?.registerSlot;
const setSlotElement = useCallback(
(element: HTMLSpanElement | null) => {
registerSlot?.(slotKey, element, { status, className });
},
[className, registerSlot, slotKey, status],
);
return (
<span
ref={setSlotElement}
aria-hidden={hidden ? "true" : undefined}
className={cn(
"inline-flex h-6 w-4 shrink-0 items-start justify-start",
hidden && "opacity-0",
)}
>
<AgentExpression status={status} className={className} />
</span>
);
}
function AgentChatExpressionTransitionOverlay({
active,
transition,
}: {
active: boolean;
transition: AgentChatExpressionTransition;
}) {
const translateX = transition.toRect.left - transition.fromRect.left;
const translateY = transition.toRect.top - transition.fromRect.top;
const scaleX = transition.fromRect.width
? transition.toRect.width / transition.fromRect.width
: 1;
const scaleY = transition.fromRect.height
? transition.toRect.height / transition.fromRect.height
: 1;
return (
<div
aria-hidden="true"
className="pointer-events-none fixed z-[90] will-change-transform"
style={{
left: transition.fromRect.left,
top: transition.fromRect.top,
width: transition.fromRect.width,
height: transition.fromRect.height,
transform: active
? `translate3d(${translateX}px, ${translateY}px, 0) scale(${scaleX}, ${scaleY})`
: "translate3d(0, 0, 0) scale(1)",
transformOrigin: "top left",
transition: `transform ${AGENT_CHAT_EXPRESSION_TRANSITION_MS}ms cubic-bezier(0.2, 0.9, 0.2, 1)`,
}}
>
<AgentExpression
status={transition.status}
className={cn(transition.className, "h-full w-full p-0")}
/>
</div>
);
}
function AgentChatReplyMarker({
disposition,
slotKey,
}: {
disposition: string;
slotKey: string;
}) {
const status =
disposition === "failed"
? "error"
: disposition === "dismissed"
? "waiting"
: "idle";
return (
<AgentChatExpressionSlot
slotKey={slotKey}
status={status}
className={cn(
"w-4 shrink-0 p-0",
disposition === "failed"
? "text-destructive"
: disposition === "dismissed"
? "text-muted-foreground"
: "text-primary",
)}
/>
);
}
function AgentChatPromptCell({
id,
title,
bodyLines,
fullBody,
imageAttachments = [],
markdown,
}: {
id: string;
title: string;
bodyLines: string[];
fullBody?: string | null;
imageAttachments?: AgentChatImageAttachmentData[];
markdown: boolean;
}) {
const text = fullBody?.trimEnd() || [title, ...bodyLines].join("\n").trimEnd();
const lines = text ? text.split("\n") : [];
return (
<div className="flex min-w-0 max-w-full flex-col gap-1 py-1 text-sm leading-6 text-foreground [overflow-wrap:anywhere]">
{lines.length > 0 ? (
<div className="grid min-w-0 grid-cols-[0.75rem_minmax(0,1fr)] items-start gap-x-2 px-2 sm:px-3">
<span
aria-hidden="true"
className="inline-flex h-6 w-3 shrink-0 items-center justify-start font-mono text-sm font-semibold leading-none text-muted-foreground"
>
›
</span>
<div className="min-w-0">
{markdown ? (
<AgentChatMarkdownText
text={text}
limit={AGENT_CHAT_FULL_MESSAGE_LINE_LIMIT}
/>
) : (
lines.map((line, index) => (
<p
key={`${id}-prompt-line-${index}`}
className="min-w-0 whitespace-pre-wrap break-words"
>
{line || "\u00a0"}
</p>
))
)}
</div>
</div>
) : null}
{imageAttachments.length > 0 ? (
<div className="flex min-w-0 max-w-full flex-col gap-2 pl-7 pr-2 sm:pl-8 sm:pr-3">
{imageAttachments.map((attachment, index) => (
<AgentChatImageAttachment
key={`${id}-prompt-image-${index}`}
label={attachment.label}
uri={attachment.uri}
mimeType={attachment.mimeType}
/>
))}
</div>
) : null}
</div>
);
}
function AgentChatDetailRows({
rows,
className,
}: {
rows: ReactNode[];
className?: string;
}) {
if (rows.length === 0) {
return null;
}
return (
<div
className={cn(
AGENT_CHAT_ACTIVITY_DETAIL_ROWS_CLASS,
className,
)}
>
{rows.map((row, index) => (
<Fragment key={`detail-row-${index}`}>
<span className="select-none font-mono text-muted-foreground">
{index === 0 ? "└" : ""}
</span>
<div className="min-w-0 break-words text-muted-foreground">
{row}
</div>
</Fragment>
))}
</div>
);
}
function AgentChatRuntimeStatusCell({
agentExpressionSlotKey,
title,
detail,
startedAtMs,
reducedMotion,
}: {
agentExpressionSlotKey?: string | null;
title: string;
detail?: string | null;
startedAtMs?: number | null;
reducedMotion?: string | null;
}) {
const [nowMs, setNowMs] = useState(() => Date.now());
const elapsedText = formatAgentChatDuration(
startedAtMs ? Math.max(0, nowMs - startedAtMs) : 0,
);
const detailText = detail?.trim();
const shouldAnimate = reducedMotion !== "Reduced";
useEffect(() => {
if (!startedAtMs) {
return;
}
const interval = window.setInterval(() => setNowMs(Date.now()), 1000);
return () => window.clearInterval(interval);
}, [startedAtMs]);
return (
<div
className={cn(
AGENT_CHAT_ACTIVITY_ROW_CLASS,
"text-sm leading-6 text-foreground/90 [overflow-wrap:anywhere]",
)}
>
{agentExpressionSlotKey ? (
<AgentChatExpressionSlot
slotKey={agentExpressionSlotKey}
status={shouldAnimate ? "running" : "waiting"}
className={cn(
"w-4 shrink-0 p-0",
shouldAnimate ? "text-primary" : "text-muted-foreground",
)}
/>
) : (
<AgentChatActivityMarker icon="activity" />
)}
<p className="min-w-0 break-words">
<span className="font-semibold text-foreground">
{shouldAnimate ? (
<AgentChatRuntimeShimmerText
text={title}
startedAtMs={startedAtMs}
/>
) : (
<AgentChatMarkdownInline text={title} />
)}
</span>{" "}
<span className="text-muted-foreground">({elapsedText})</span>
{detailText ? (
<>
<span className="text-muted-foreground"> — </span>
<span className="text-muted-foreground">{detailText}</span>
</>
) : null}
</p>
</div>
);
}
function AgentChatRuntimeShimmerText({
text,
startedAtMs,
}: {
text: string;
startedAtMs?: number | null;
}) {
const baseDelayMs = useMemo(() => {
if (!startedAtMs) {
return 0;
}
return -(
Math.max(0, Date.now() - startedAtMs) % AGENT_CHAT_RUNTIME_SHIMMER_MS
);
}, [startedAtMs]);
return (
<span className="agent-chat-runtime-shimmer" aria-label={text}>
{Array.from(text).map((char, index) => (
<span
key={`${index}-${char}`}
aria-hidden="true"
className="agent-chat-runtime-shimmer-letter"
style={{
animationDelay: `${
baseDelayMs + index * AGENT_CHAT_RUNTIME_SHIMMER_STAGGER_MS
}ms`,
}}
>
{char}
</span>
))}
</span>
);
}
function AgentChatActivityTextCell({
id,
icon,
marker,
title,
bodyLines,
fullBody,
imageAttachments = [],
bodyLimit,
tone = "default",
preserveSoftBreaks = false,
}: {
id: string;
icon: AgentChatActivityMarkerKind;
marker?: ReactNode;
title: string;
bodyLines: string[];
fullBody?: string | null;
imageAttachments?: AgentChatImageAttachmentData[];
bodyLimit?: number;
tone?: "default" | "error" | "muted";
preserveSoftBreaks?: boolean;
}) {
const renderedText = fullBody
? fullBody.split("\n").slice(1).join("\n")
: bodyLines.join("\n");
const renderedLineCount = renderedText ? renderedText.split("\n").length : 0;
return (
<div
className={cn(
"flex min-w-0 max-w-full flex-col gap-1 text-sm leading-6 text-foreground/90 [overflow-wrap:anywhere]",
tone === "error" && "text-destructive",
tone === "muted" && "text-muted-foreground",
)}
>
<div className={AGENT_CHAT_ACTIVITY_ROW_CLASS}>
{marker ?? (
<AgentChatActivityMarker
icon={icon}
tone={tone === "error" ? "error" : "default"}
/>
)}
<p
className={cn(
"min-w-0 break-words font-semibold text-foreground",
tone === "error" && "text-destructive",
tone === "muted" && "text-foreground/90",
)}
>
<AgentChatMarkdownInline text={title} />
</p>
</div>
{renderedLineCount > 0 ? (
<div
className={cn(
"flex min-w-0 max-w-full flex-col gap-0.5 pl-8 pr-2 text-muted-foreground sm:pl-10 sm:pr-3",
tone === "error" && "text-destructive/90",
tone === "muted" && "text-muted-foreground",
)}
>
<AgentChatMarkdownText
text={renderedText}
limit={bodyLimit ?? AGENT_CHAT_FULL_MESSAGE_LINE_LIMIT}
tone={tone === "error" ? "error" : "default"}
preserveSoftBreaks={preserveSoftBreaks}
/>
</div>
) : null}
{imageAttachments.length > 0 ? (
<div className="flex min-w-0 max-w-full flex-col gap-2 px-2 sm:px-3">
{imageAttachments.map((attachment, index) => (
<AgentChatImageAttachment
key={`${id}-activity-image-${index}`}
label={attachment.label}
uri={attachment.uri}
mimeType={attachment.mimeType}
/>
))}
</div>
) : null}
</div>
);
}
function AgentChatStatusLineCell({
icon,
label,
value,
suffix = "",
valueClassName,
}: {
icon: AgentChatActivityMarkerKind;
label: string;
value?: string;
suffix?: string;
valueClassName?: string;
}) {
return (
<div
className={cn(
AGENT_CHAT_ACTIVITY_ROW_CLASS,
"max-w-full text-sm leading-6 text-foreground/90 [overflow-wrap:anywhere]",
)}
>
<AgentChatActivityMarker icon={icon} />
<p className="min-w-0 break-words font-semibold text-foreground">
{label}
{value ? (
<>
{" "}
<span className={cn("text-foreground/90", valueClassName)}>
{value}
</span>
{suffix}
</>
) : null}
</p>
</div>
);
}
function AgentChatThinkingCollapsibleCell({
content,
bodyLimit,
}: {
content: string;
bodyLimit: number;
}) {
const { open, toggle } = useCollapsibleState(false);
const contentText = normalizeThinkingMarkdown(content.trimEnd());
const isTruncatable = contentText.split(/\r?\n/).length > bodyLimit;
return (
<div className="ml-3 flex min-w-0 max-w-full flex-col gap-0.5 border-l-2 border-muted pl-3 text-sm leading-6 text-foreground/90 [overflow-wrap:anywhere]">
<div className="flex items-center gap-1.5 min-w-0">
<p className="min-w-0 break-words font-semibold text-foreground">
<AgentChatMarkdownInline text="Thinking" />
</p>
{isTruncatable ? (
<CollapsibleTrigger
open={open}
onToggle={toggle}
className="ml-auto shrink-0 w-auto text-xs"
>
{open ? "Hide" : "Expand"}
</CollapsibleTrigger>
) : null}
</div>
{contentText.trim() ? (
<div
className={cn(
"relative min-w-0 max-w-full text-muted-foreground",
!open && isTruncatable && "max-h-[4.5rem] overflow-hidden",
)}
>
<AgentChatMarkdownText
text={contentText}
limit={AGENT_CHAT_FULL_MESSAGE_LINE_LIMIT}
tone="muted"
preserveSoftBreaks
/>
{!open && isTruncatable ? (
<div className="absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-background to-transparent pointer-events-none" />
) : null}
</div>
) : null}
</div>
);
}
function AgentChatPlanActivityPanel({
icon,
title,
steps,
}: {
icon: AgentChatActivityMarkerKind;
title: string;
steps: AgentChatPlanStep[];
}) {
const visibleSteps = steps.slice(0, AGENT_CHAT_PLAN_STEP_LIMIT);
const rows =
visibleSteps.length > 0
? visibleSteps.map((step) => (
<AgentChatPlanStepLine key={`${step.status}-${step.text}`} step={step} />
))
: [<span className="text-muted-foreground/75">No active plan.</span>];
return (
<div className="flex min-w-0 max-w-full flex-col gap-1 text-sm [overflow-wrap:anywhere]">
<div className={cn(AGENT_CHAT_ACTIVITY_ROW_CLASS, "leading-6")}>
<AgentChatActivityMarker icon={icon} />
<p className="min-w-0 break-words font-semibold text-foreground [overflow-wrap:anywhere]">
{title}
</p>
</div>
<AgentChatDetailRows rows={rows} />
</div>
);
}
function AgentChatPlanStepLine({
step,
}: {
step: AgentChatPlanStep;
}) {
const marker = step.status === "completed" ? "✔" : "□";
const isCurrent = step.status === "in_progress";
return (
<p
className={cn(
"min-w-0 break-words text-muted-foreground",
isCurrent && "font-semibold text-primary",
step.status === "completed" && "text-muted-foreground/65 line-through",
)}
>
<span className="mr-1 font-mono">{marker}</span>
{step.text}
</p>
);
}
function AgentChatCommandExecutionPanel({
mode,
icon,
title,
outputLines,
}: {
mode: "running" | "completed";
icon: AgentChatActivityMarkerKind;
title: string;
outputLines: string[];
exitCode?: number | null;
}) {
const renderedOutput =
outputLines.length > 0
? truncateAgentChatLinesMiddle(
outputLines,
AGENT_CHAT_TERMINAL_OUTPUT_HEAD_LINES,
AGENT_CHAT_TERMINAL_OUTPUT_TAIL_LINES,
)
: [mode === "running" ? "running..." : "(no output)"];
const verb = mode === "running" ? "Running" : "Ran";
return (
<div className="flex min-w-0 max-w-full flex-col gap-1 text-sm [overflow-wrap:anywhere]">
<div className={cn(AGENT_CHAT_ACTIVITY_ROW_CLASS, "leading-6")}>
{mode === "running" ? (
<AgentChatActivityMarker
icon={icon}
className="motion-safe:animate-pulse"
/>
) : (
<AgentChatActivityMarker icon={icon} />
)}
<p
className="min-w-0 flex-1 truncate font-semibold text-foreground"
title={`${verb} ${title}`}
>
{verb}{" "}
<span className="font-mono font-medium text-foreground/90">
{title}
</span>
</p>
</div>
<AgentChatTerminalOutputBlock lines={renderedOutput} />
</div>
);
}
function AgentChatTerminalOutputBlock({ lines }: { lines: string[] }) {
const rows = lines.map((line, index) => (
<pre
key={`terminal-output-${index}`}
className={cn(
"min-w-0 whitespace-pre-wrap break-words font-mono text-xs leading-5 text-muted-foreground",
line.startsWith("… +") && "text-muted-foreground/70",
)}
>
{line}
</pre>
));
return (
<AgentChatDetailRows rows={rows} />
);
}
function AgentChatExploredActivityPanel({
icon,
title,
calls,
}: {
icon: AgentChatActivityMarkerKind;
title: string;
calls: AgentChatExploredCall[];
}) {
const rows = agentChatExploredDetailRows(calls);
return (
<div className="flex min-w-0 max-w-full flex-col gap-1 text-sm [overflow-wrap:anywhere]">
<div className={cn(AGENT_CHAT_ACTIVITY_ROW_CLASS, "leading-6")}>
<AgentChatActivityMarker icon={icon} />
<p className="min-w-0 break-words font-semibold text-foreground">
{title}
</p>
</div>
{rows.length > 0 ? (
<AgentChatDetailRows rows={rows} />
) : (
<p className="px-2 text-xs text-muted-foreground sm:px-3">
No explored tool calls
</p>
)}
</div>
);
}
function AgentChatPatchActivityPanel({
icon,
title,
files,
}: {
icon: AgentChatActivityMarkerKind;
title: string;
files: AgentChatDiffFile[];
}) {
const rows = files.map((file, index) => (
<AgentChatPatchFileBlock
key={`${file.path}-${index}`}
file={file}
hideHeader={files.length === 1}
/>
));
return (
<div className="flex min-w-0 max-w-full flex-col gap-1.5 text-sm [overflow-wrap:anywhere]">
<div className={cn(AGENT_CHAT_ACTIVITY_ROW_CLASS, "leading-6")}>
<AgentChatActivityMarker icon={icon} />
<p className="min-w-0 break-words font-semibold text-foreground">
{title}
</p>
</div>
{files.length > 0 ? (
<AgentChatDetailRows rows={rows} />
) : (
<p className="px-2 text-xs text-muted-foreground sm:px-3">No file changes</p>
)}
</div>
);
}
function AgentChatPatchFileBlock({
file,
hideHeader = false,
}: {
file: AgentChatDiffFile;
hideHeader?: boolean;
}) {
const oldWidth = agentChatDiffLineNumberWidth(file.lines, "old_lineno");
const newWidth = agentChatDiffLineNumberWidth(file.lines, "new_lineno");
const highlighted = useShikiHighlightedCode(
agentChatDiffHighlightSource(file.lines),
file.path,
);
return (
<div className="flex min-w-0 max-w-full flex-col gap-1">
{!hideHeader ? (
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<p className="min-w-0 break-all font-mono text-xs font-semibold text-foreground/90">
{file.path}
</p>
<span className="text-[0.68rem] font-medium leading-none text-muted-foreground">
{file.operation}
</span>
<span className="font-mono text-[0.7rem] text-muted-foreground">
+{file.added_lines} -{file.removed_lines}
</span>
</div>
) : null}
{file.lines.length > 0 ? (
<div className="min-w-0 max-w-full overflow-hidden font-mono text-xs leading-5 [overflow-wrap:anywhere]">
{file.lines.map((line, index) => (
<AgentChatPatchDiffRow
key={`patch-line-${index}`}
line={line}
oldWidth={oldWidth}
newWidth={newWidth}
highlightedLine={highlighted?.lines[index]}
/>
))}
</div>
) : null}
</div>
);
}
function AgentChatPatchDiffRow({
line,
oldWidth,
newWidth,
highlightedLine,
}: {
line: AgentChatDiffLine;
oldWidth: number;
newWidth: number;
highlightedLine?: ShikiHighlightToken[];
}) {
if (line.kind === "hunk_break") {
return (
<div className="grid min-w-0 grid-cols-[var(--old-width)_var(--new-width)_1rem_minmax(0,1fr)] gap-1 px-2 py-0.5 text-muted-foreground/70 [--new-width:2.5rem] [--old-width:2.5rem] sm:gap-2 sm:px-3">
<span>{"".padStart(oldWidth, " ")}</span>
<span>{"".padStart(newWidth, " ")}</span>
<span>⋮</span>
<span />
</div>
);
}
const oldLineNumber =
typeof line.old_lineno === "number"
? String(line.old_lineno).padStart(oldWidth, " ")
: "".padStart(oldWidth, " ");
const newLineNumber =
typeof line.new_lineno === "number"
? String(line.new_lineno).padStart(newWidth, " ")
: "".padStart(newWidth, " ");
const gutter = line.kind === "add" ? "+" : line.kind === "delete" ? "-" : " ";
return (
<div
className={cn(
"grid min-w-0 grid-cols-[var(--old-width)_var(--new-width)_1rem_minmax(0,1fr)] gap-1 px-2 py-0.5 sm:gap-2 sm:px-3",
"[--new-width:2.5rem] [--old-width:2.5rem]",
agentChatDiffRowToneClassName(line.kind),
)}
>
<span className="select-none text-right text-muted-foreground/65">
{oldLineNumber}
</span>
<span className="select-none text-right text-muted-foreground/65">
{newLineNumber}
</span>
<span
className={cn(
"select-none font-semibold text-muted-foreground",
agentChatDiffGutterToneClassName(line.kind),
)}
>
{gutter}
</span>
<span className="min-w-0 whitespace-pre-wrap break-words text-foreground/85 [overflow-wrap:anywhere]">
<AgentChatHighlightedInline
tokens={highlightedLine}
fallback={line.text}
/>
</span>
</div>
);
}
function agentChatDiffHighlightSource(lines: AgentChatDiffLine[]) {
return lines
.map((line) => (line.kind === "hunk_break" ? "" : line.text))
.join("\n");
}
function agentChatDiffLinePrefix(line: AgentChatDiffLine) {
if (line.kind === "hunk_break") {
return " ";
}
return line.kind === "add" ? "+ " : line.kind === "delete" ? "- " : " ";
}
function agentChatDiffRowToneClassName(kind: string) {
if (kind === "add") {
return "bg-emerald-50/90 dark:bg-emerald-500/10";
}
if (kind === "delete") {
return "bg-red-50/90 dark:bg-red-500/10";
}
return "";
}
function agentChatDiffGutterToneClassName(kind: string) {
if (kind === "add") {
return "text-emerald-700 dark:text-emerald-300/90";
}
if (kind === "delete") {
return "text-red-700 dark:text-red-300/90";
}
return "";
}
function AgentChatMessageActivityLine({
id,
icon,
title,
detailLines,
messageLines,
detailLimit,
messageLimit,
}: {
id: string;
icon: AgentChatActivityMarkerKind;
title: string;
detailLines: string[];
messageLines: string[];
detailLimit: number;
messageLimit: number;
}) {
const visibleDetailLines = detailLines.slice(0, detailLimit);
const hiddenDetailCount = detailLines.length - visibleDetailLines.length;
const visibleMessageLines = messageLines.slice(0, messageLimit);
const hiddenMessageCount = messageLines.length - visibleMessageLines.length;
const visibleMessageText = visibleMessageLines.join("\n").trimEnd();
return (
<div className="flex min-w-0 max-w-full flex-col gap-1 text-sm leading-6 text-foreground/90 [overflow-wrap:anywhere]">
<div className={AGENT_CHAT_ACTIVITY_ROW_CLASS}>
<AgentChatActivityMarker icon={icon} />
<p className="min-w-0 break-words font-semibold text-foreground">
{title}
</p>
</div>
{visibleDetailLines.length > 0 || hiddenDetailCount > 0 ? (
<div className="flex min-w-0 max-w-full flex-col gap-0.5 pl-8 pr-2 text-xs leading-5 text-muted-foreground sm:pl-10 sm:pr-3">
{visibleDetailLines.map((line, index) => (
<p key={`${id}-detail-${index}`} className="break-words">
{line}
</p>
))}
{hiddenDetailCount > 0 ? (
<p>… {hiddenDetailCount} more line(s)</p>
) : null}
</div>
) : null}
{visibleMessageText || hiddenMessageCount > 0 ? (
<div className="flex min-w-0 max-w-full flex-col gap-0.5 px-2 text-foreground/90 sm:px-3">
{visibleMessageText ? (
<AgentChatMarkdownText
text={visibleMessageText}
limit={AGENT_CHAT_FULL_MESSAGE_LINE_LIMIT}
/>
) : null}
{hiddenMessageCount > 0 ? (
<p className="text-xs text-muted-foreground">
… {hiddenMessageCount} more line(s)
</p>
) : null}
</div>
) : null}
</div>
);
}
function AgentChatReplyActivityLine({
id,
title,
messageLines,
disposition,
subject,
isLatestReply = false,
}: {
id: string;
title: string;
messageLines: string[];
disposition: string;
subject: string;
isLatestReply?: boolean;
}) {
const [hasCopiedReply, setHasCopiedReply] = useState(false);
const originalText = messageLines.join("\n");
async function handleCopyReply() {
try {
await navigator.clipboard.writeText(originalText);
setHasCopiedReply(true);
window.setTimeout(() => setHasCopiedReply(false), 1600);
} catch {
setHasCopiedReply(false);
}
}
const replyMarker = isLatestReply ? (
<AgentChatReplyMarker
disposition={disposition}
slotKey={agentChatExpressionSlotKey("reply", id)}
/>
) : null;
if (disposition === "resolved" && subject === "message") {
const agentMessage = agentChatAgentMessageFromLines(messageLines);
if (!agentMessage) {
return null;
}
return (
<>
<div className="flex min-w-0 max-w-full flex-col gap-1 text-sm leading-6 text-foreground/90 [overflow-wrap:anywhere]">
<div className={AGENT_CHAT_ACTIVITY_ROW_CLASS}>
{replyMarker ?? <AgentChatActivityMarker icon="activity" />}
<AgentChatMarkdownText
text={agentMessage.fullBody}
limit={AGENT_CHAT_FULL_MESSAGE_LINE_LIMIT}
/>
</div>
</div>
<div className="-mb-1 mt-1 flex justify-start pl-8 sm:pl-10">
<button
type="button"
onClick={handleCopyReply}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[0.7rem] text-muted-foreground/60 transition-colors hover:bg-muted/50 hover:text-muted-foreground"
aria-label="Copy"
>
{hasCopiedReply ? (
<CheckIcon className="h-3 w-3" aria-hidden="true" />
) : (
<CopyIcon className="h-3 w-3" aria-hidden="true" />
)}
</button>
</div>
</>
);
}
return (
<>
<div
className={cn(
"flex min-w-0 max-w-full flex-col gap-1 text-sm leading-6 text-foreground/90 [overflow-wrap:anywhere]",
disposition === "failed" && "text-destructive",
disposition === "dismissed" && "text-muted-foreground",
)}
>
<div className={cn(AGENT_CHAT_ACTIVITY_ROW_CLASS, "leading-6")}>
{replyMarker ?? (
<AgentChatActivityMarker
icon="activity"
tone={disposition === "failed" ? "error" : "default"}
className={
disposition === "dismissed" ? "text-muted-foreground" : undefined
}
/>
)}
<p
className={cn(
"min-w-0 break-words font-semibold text-foreground",
disposition === "failed" && "text-destructive",
disposition === "dismissed" && "text-muted-foreground",
)}
>
{title}
</p>
</div>
{messageLines.length > 0 ? (
<div className="px-2 text-foreground/90 sm:px-3">
<AgentChatMarkdownText
text={messageLines.join("\n")}
limit={AGENT_CHAT_FULL_MESSAGE_LINE_LIMIT}
tone={disposition === "failed" ? "error" : "default"}
/>
</div>
) : null}
<div className="-mb-1 mt-1 flex justify-start px-2 sm:px-3">
<button
type="button"
onClick={handleCopyReply}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[0.7rem] text-muted-foreground/60 transition-colors hover:bg-muted/50 hover:text-muted-foreground"
aria-label="Copy"
>
{hasCopiedReply ? (
<CheckIcon className="h-3 w-3" aria-hidden="true" />
) : (
<CopyIcon className="h-3 w-3" aria-hidden="true" />
)}
</button>
</div>
</div>
</>
);
}
function AgentChatBlock({
block,
blockId,
isFocused,
messageMode = false,
}: {
block: AgentChatBlock;
blockId: string;
isFocused: boolean;
messageMode?: boolean;
}) {
const record = asRecord(block);
const type = typeof record?.type === "string" ? record.type : "unknown";
const lineLimit =
messageMode
? isFocused
? AGENT_CHAT_FULL_MESSAGE_LINE_LIMIT
: AGENT_CHAT_MESSAGE_LINE_LIMIT
: AGENT_CHAT_ACTIVITY_BLOCK_LINE_LIMIT;
if (!record) {
return null;
}
if (type === "text") {
return (
<AgentChatMarkdownText
text={stringValue(record.text, "")}
limit={lineLimit}
/>
);
}
if (type === "code") {
return (
<AgentChatCodeBlock
id={blockId}
code={stringValue(record.code, "")}
language={stringValue(record.language, "")}
/>
);
}
if (type === "kv") {
const entries = kvEntriesValue(record.entries);
return entries.length > 0 ? (
<dl className="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs text-muted-foreground">
{entries.map((entry, index) => (
<FragmentPair
key={`${blockId}-kv-${index}`}
left={entry.key}
right={entry.value}
/>
))}
</dl>
) : null;
}
if (type === "list") {
const items = stringArrayValue(record.items);
return items.length > 0 ? (
<AgentChatListItems blockId={blockId} items={items} limit={lineLimit} />
) : null;
}
if (type === "diff") {
return (
<AgentChatDiffBlock id={blockId} files={diffFilesValue(record.files)} />
);
}
if (type === "link") {
const url = stringValue(record.url, "");
const label = stringValue(record.label, url);
return url ? (
<a
href={url}
target="_blank"
rel="noreferrer"
className="break-all text-primary underline-offset-4 hover:underline"
>
{label}
</a>
) : null;
}
if (type === "image" || type === "artifact") {
const label = stringValue(record.label, "Artifact");
const uri = stringValue(record.uri, "");
const mimeType = stringValue(record.mime_type, "");
if (uri && (type === "image" || mimeType.startsWith("image/"))) {
return (
<AgentChatImageAttachment label={label} uri={uri} mimeType={mimeType} />
);
}
return uri ? (
<a
href={uri}
target="_blank"
rel="noreferrer"
className="break-all text-primary underline-offset-4 hover:underline"
>
{label}
</a>
) : (
<p className="break-words text-muted-foreground">{label}</p>
);
}
return (
<p className="break-words text-xs text-muted-foreground">
Unsupported activity block: {safeJsonPreview(record)}
</p>
);
}
function FragmentPair({ left, right }: { left: string; right: string }) {
return (
<>
<dt className="font-medium text-muted-foreground/80">{left}</dt>
<dd className="min-w-0 break-words text-foreground/80">{right}</dd>
</>
);
}
function limitMarkdownInput(text: string, limit: number): string {
if (limit >= Number.MAX_SAFE_INTEGER) return text;
const lines = text.split("\n");
if (lines.length <= limit) return text;
return lines.slice(0, limit).join("\n");
}
type MarkdownCodeElementProps = {
className?: string;
children?: ReactNode;
};
function markdownNodeText(node: ReactNode): string {
if (node === null || node === undefined || typeof node === "boolean") {
return "";
}
if (
typeof node === "string" ||
typeof node === "number" ||
typeof node === "bigint"
) {
return String(node);
}
if (Array.isArray(node)) {
return node.map(markdownNodeText).join("");
}
if (isValidElement(node)) {
return markdownNodeText(
(node.props as { children?: ReactNode }).children,
);
}
return "";
}
function markdownCodeLanguage(className: unknown): string {
return (
String(className ?? "")
.split(/\s+/)
.find((name) => name.startsWith("language-"))
?.replace(/^language-/, "") ?? ""
);
}
function markdownPreCodeProps(children: ReactNode) {
const child = Array.isArray(children)
? children.find((item) => isValidElement(item))
: children;
if (!isValidElement(child)) {
return null;
}
return child.props as MarkdownCodeElementProps;
}
const AgentChatMarkdownText = memo(function AgentChatMarkdownText({
text,
limit,
tone = "default",
preserveSoftBreaks = false,
}: {
text: string;
limit: number;
tone?: "default" | "error" | "muted";
preserveSoftBreaks?: boolean;
}) {
const limitedText = limitMarkdownInput(text, limit);
const markdownId = useId();
if (!limitedText.trim()) {
return null;
}
return (
<div
className={cn(
"flex min-w-0 max-w-full flex-col gap-2 text-sm leading-6 text-foreground/90 [overflow-wrap:anywhere]",
tone === "error" && "text-destructive",
tone === "muted" && "text-muted-foreground",
)}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }: any) => (
<h3 className="mt-3 break-words text-base font-semibold leading-7 text-foreground first:mt-0">
{children}
</h3>
),
h2: ({ children }: any) => (
<h4 className="mt-3 break-words text-base font-semibold leading-7 text-foreground first:mt-0">
{children}
</h4>
),
h3: ({ children }: any) => (
<h5 className="mt-3 break-words text-base font-semibold leading-7 text-foreground first:mt-0">
{children}
</h5>
),
h4: ({ children }: any) => (
<h5 className="mt-3 break-words text-base font-semibold leading-7 text-foreground first:mt-0">
{children}
</h5>
),
h5: ({ children }: any) => (
<h5 className="mt-3 break-words text-base font-semibold leading-7 text-foreground first:mt-0">
{children}
</h5>
),
h6: ({ children }: any) => (
<h5 className="mt-3 break-words text-base font-semibold leading-7 text-foreground first:mt-0">
{children}
</h5>
),
ul: ({ children }: any) => (
<ul className="flex list-disc flex-col gap-1 pl-5 text-foreground/90">
{children}
</ul>
),
ol: ({ children }: any) => (
<ol className="flex list-decimal flex-col gap-1 pl-5 text-foreground/90">
{children}
</ol>
),
li: ({ children }: any) => (
<li className="break-words pl-1">{children}</li>
),
blockquote: ({ children }: any) => (
<blockquote className="border-l-2 border-border/70 pl-3 text-muted-foreground">
{children}
</blockquote>
),
hr: () => <Separator />,
table: ({ children }: any) => (
<div
aria-label="Scrollable markdown table"
className="my-2 min-w-0 max-w-full overflow-x-auto rounded-xl border border-border/80 bg-background/70 [scrollbar-color:hsl(var(--muted-foreground)/0.35)_transparent] [scrollbar-gutter:stable] [scrollbar-width:thin]"
tabIndex={0}
>
<table className="w-full min-w-[44rem] border-separate border-spacing-0 text-left text-[0.8125rem] leading-5 [overflow-wrap:normal]">
{children}
</table>
</div>
),
thead: ({ children }: any) => (
<thead className="bg-muted/65 text-muted-foreground">{children}</thead>
),
tbody: ({ children }: any) => (
<tbody className="divide-y divide-border/70">{children}</tbody>
),
tr: ({ children }: any) => <tr>{children}</tr>,
th: ({ align, children }: any) => (
<th
className="min-w-32 border-b border-border/80 px-4 py-3 align-top text-left text-[0.7rem] font-semibold tracking-[0.08em] text-muted-foreground"
style={align ? { textAlign: align } : undefined}
>
{children}
</th>
),
td: ({ align, children }: any) => (
<td
className="min-w-32 px-4 py-3 align-top text-foreground/90 [overflow-wrap:normal]"
style={align ? { textAlign: align } : undefined}
>
{children}
</td>
),
p: ({ children }: any) => (
<p
className={cn(
"break-words",
preserveSoftBreaks && "whitespace-pre-wrap",
)}
>
{children}
</p>
),
pre: ({ children }: { children?: ReactNode }) => {
const codeProps = markdownPreCodeProps(children);
if (codeProps) {
const language = markdownCodeLanguage(codeProps.className);
const code = markdownNodeText(codeProps.children).replace(/\n$/, "");
return (
<AgentChatCodeBlock
id={`${markdownId}-code-${language || "plain"}`}
code={code}
language={language}
/>
);
}
return (
<pre className="min-w-0 max-w-full overflow-x-auto overflow-y-visible whitespace-pre rounded-3xl bg-muted/55 px-6 py-5 pb-8 font-mono text-xs leading-5 text-foreground/90 [scrollbar-color:hsl(var(--muted-foreground)/0.35)_transparent] [scrollbar-gutter:stable] [scrollbar-width:thin]">
{children}
</pre>
);
},
code: ({ children }: { children?: ReactNode }) => {
return (
<code className="font-mono text-[0.85em] text-foreground">
{children}
</code>
);
},
a: (props: any) => {
const { children, href } = props;
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="break-all text-primary underline-offset-4 hover:underline"
>
{children}
</a>
);
},
strong: ({ children }: any) => (
<strong className="font-semibold text-foreground">
{children}
</strong>
),
em: ({ children }: any) => <em className="italic">{children}</em>,
}}
>
{limitedText}
</ReactMarkdown>
</div>
);
});
function AgentChatMarkdownInline({ text }: { text: string }) {
if (!text) {
return null;
}
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
disallowedElements={[
"p",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"ul",
"ol",
"li",
"blockquote",
"pre",
"hr",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
]}
unwrapDisallowed
components={{
code: ({ children }: any) => (
<code className="font-mono text-[0.85em] text-foreground">
{children}
</code>
),
a: (props: any) => {
const { children, href } = props;
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="break-all text-primary underline-offset-4 hover:underline"
>
{children}
</a>
);
},
strong: ({ children }: any) => (
<strong className="font-semibold text-foreground">{children}</strong>
),
em: ({ children }: any) => <em className="italic">{children}</em>,
}}
>
{text}
</ReactMarkdown>
);
}
function AgentChatListItems({
blockId,
items,
limit,
dense = false,
}: {
blockId: string;
items: string[];
limit: number;
dense?: boolean;
}) {
const visibleItems = items.slice(0, limit);
const hiddenItems = items.slice(limit);
return (
<ul
className={cn(
"flex flex-col gap-1 text-foreground/90",
dense ? "text-xs" : "text-sm",
)}
>
{visibleItems.map((item, index) => (
<li key={`${blockId}-item-${index}`} className="flex gap-2 break-words">
<span className="text-muted-foreground">•</span>
<span>{item}</span>
</li>
))}
{hiddenItems.length > 0 ? (
<li className="text-xs text-muted-foreground">
… +{hiddenItems.length} more
</li>
) : null}
</ul>
);
}
function AgentChatImageAttachment({
label,
uri,
mimeType,
}: {
label: string;
uri: string;
mimeType: string;
}) {
const imageUrl = getDashboardAttachmentUrl(uri);
const title = [label, mimeType].filter(Boolean).join(" · ");
return (
<figure className="min-w-0 max-w-[min(28rem,100%)] overflow-hidden rounded-lg border border-border/60 bg-muted/20">
<a href={imageUrl} target="_blank" rel="noreferrer" className="block">
<img
src={imageUrl}
alt={label}
title={title || label}
loading="lazy"
className="max-h-[22rem] w-full object-contain"
/>
</a>
</figure>
);
}
function useShikiHighlightedCode(code: string, languageOrPath: string) {
const colorScheme = useAgentChatCodeColorScheme();
const [highlighted, setHighlighted] = useState<ShikiHighlightedCode | null>(
null,
);
useEffect(() => {
let cancelled = false;
setHighlighted(null);
void highlightCodeWithShiki(code, languageOrPath, colorScheme).then(
(nextHighlighted) => {
if (!cancelled) {
setHighlighted(nextHighlighted);
}
},
);
return () => {
cancelled = true;
};
}, [code, colorScheme, languageOrPath]);
return highlighted;
}
function useAgentChatCodeColorScheme(): ShikiColorScheme {
const [colorScheme, setColorScheme] = useState(agentChatCodeColorScheme);
useEffect(() => {
if (typeof document === "undefined") {
return;
}
const root = document.documentElement;
const update = () => setColorScheme(agentChatCodeColorScheme());
const observer = new MutationObserver(update);
observer.observe(root, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect();
}, []);
return colorScheme;
}
function agentChatCodeColorScheme(): ShikiColorScheme {
if (
typeof document !== "undefined" &&
document.documentElement.classList.contains("dark")
) {
return "dark";
}
return "light";
}
const AgentChatCodeBlock = memo(function AgentChatCodeBlock({
id,
code,
language,
}: {
id: string;
code: string;
language: string;
}) {
const [hasCopied, setHasCopied] = useState(false);
const label = agentChatCodeLanguageLabel(language);
const canCopy =
typeof navigator !== "undefined" && Boolean(navigator.clipboard);
const highlighted = useShikiHighlightedCode(code, language);
async function handleCopyCode() {
if (!canCopy) {
return;
}
try {
await navigator.clipboard.writeText(code);
setHasCopied(true);
window.setTimeout(() => setHasCopied(false), 1600);
} catch {
setHasCopied(false);
}
}
if (!code.trim()) {
return <p className="text-muted-foreground">(no output)</p>;
}
return (
<div className="flex min-w-0 max-w-full flex-col gap-3 rounded-3xl bg-muted/55 px-6 py-5">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<span
aria-hidden="true"
className="inline-flex shrink-0 items-center justify-center text-foreground"
>
<span className="font-mono text-sm font-semibold leading-none">
</>
</span>
</span>
<span className="truncate text-sm font-semibold text-foreground">
{label}
</span>
</div>
{canCopy ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Copy ${label} code`}
onClick={handleCopyCode}
>
{hasCopied ? (
<CheckIcon data-icon="inline-start" aria-hidden="true" />
) : (
<CopyIcon data-icon="inline-start" aria-hidden="true" />
)}
</Button>
) : null}
</div>
<div className="min-w-0">
<pre
className="min-w-0 max-w-full overflow-x-auto overflow-y-visible whitespace-pre pb-4 font-mono text-[0.82rem] leading-6 text-foreground [scrollbar-color:hsl(var(--muted-foreground)/0.35)_transparent] [scrollbar-gutter:stable] [scrollbar-width:thin]"
data-code-block-id={id}
>
{highlighted ? (
<AgentChatHighlightedCodeLines
lines={highlighted.lines}
lineKeyPrefix={`${id}-line`}
/>
) : (
code
)}
</pre>
</div>
</div>
);
});
function AgentChatHighlightedCodeLines({
lines,
lineKeyPrefix,
}: {
lines: ShikiHighlightToken[][];
lineKeyPrefix: string;
}) {
return (
<>
{lines.map((line, lineIndex) => (
<Fragment key={`${lineKeyPrefix}-${lineIndex}`}>
<AgentChatHighlightedInline
tokens={line}
fallback={line.length === 0 ? " " : ""}
/>
{lineIndex < lines.length - 1 ? "\n" : null}
</Fragment>
))}
</>
);
}
function AgentChatHighlightedInline({
tokens,
fallback,
}: {
tokens: ShikiHighlightToken[] | null | undefined;
fallback: string;
}) {
if (!tokens || tokens.length === 0) {
return <>{fallback}</>;
}
return (
<>
{tokens.map((token, index) => (
<span
key={`${index}-${token.content}`}
style={agentChatShikiTokenStyle(token)}
>
{token.content}
</span>
))}
</>
);
}
function agentChatShikiTokenStyle(token: ShikiHighlightToken): CSSProperties {
const style: CSSProperties = {};
if (token.color) {
style.color = token.color;
}
if (typeof token.fontStyle === "number") {
if (token.fontStyle & 1) {
style.fontStyle = "italic";
}
if (token.fontStyle & 2) {
style.fontWeight = 700;
}
if (token.fontStyle & 4) {
style.textDecorationLine = "underline";
}
}
return style;
}
function agentChatCodeLanguageLabel(language: string) {
const normalized = language.trim().toLowerCase();
if (!normalized) {
return "Code";
}
const labels: Record<string, string> = {
bash: "Bash",
css: "CSS",
html: "HTML",
js: "JavaScript",
json: "JSON",
jsx: "JSX",
md: "Markdown",
py: "Python",
python: "Python",
rs: "Rust",
rust: "Rust",
sh: "Shell",
shell: "Shell",
ts: "TypeScript",
tsx: "TSX",
zsh: "Zsh",
};
return (
labels[normalized] ??
`${normalized[0]?.toUpperCase() ?? ""}${normalized.slice(1)}`
);
}
function AgentChatDiffBlock({
id,
files,
}: {
id: string;
files: AgentChatDiffFile[];
}) {
if (files.length === 0) {
return null;
}
return (
<div className="flex min-w-0 max-w-full flex-col gap-2 font-mono text-xs">
{files.map((file, fileIndex) => (
<AgentChatDiffBlockFile key={`${id}-file-${fileIndex}`} file={file} />
))}
</div>
);
}
function AgentChatDiffBlockFile({
file,
}: {
file: AgentChatDiffFile;
}) {
const highlighted = useShikiHighlightedCode(
agentChatDiffHighlightSource(file.lines),
file.path,
);
return (
<div className="flex min-w-0 max-w-full flex-col gap-1">
<div className="flex items-center justify-between gap-3 px-2 sm:px-3">
<p className="min-w-0 truncate text-foreground/85">{file.path}</p>
<span className="shrink-0 font-sans text-[0.68rem] text-muted-foreground">
<span className="text-primary">+{file.added_lines}</span>{" "}
<span className="text-destructive">-{file.removed_lines}</span>
</span>
</div>
<pre className="max-h-72 min-w-0 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap break-words px-2 leading-5 [overflow-wrap:anywhere] [scrollbar-color:hsl(var(--muted-foreground)/0.35)_transparent] [scrollbar-width:thin] sm:px-3">
{file.lines.map((line, lineIndex) => (
<Fragment key={`${file.path}-legacy-diff-${lineIndex}`}>
<span
className={cn(
"inline-block w-full max-w-full whitespace-pre-wrap break-words align-top [overflow-wrap:anywhere]",
agentChatDiffRowToneClassName(line.kind),
)}
>
<span
className={cn(
"text-muted-foreground",
agentChatDiffGutterToneClassName(line.kind),
)}
>
{agentChatDiffLinePrefix(line)}
</span>
<AgentChatHighlightedInline
tokens={highlighted?.lines[lineIndex]}
fallback={line.text}
/>
</span>
{lineIndex < file.lines.length - 1 ? "\n" : null}
</Fragment>
))}
</pre>
</div>
);
}
function agentChatBubblesFromSnapshot(
snapshot: DashboardSnapshot | null,
): AgentChatBubble[] {
if (!snapshot) {
return [];
}
const committed = agentChatCommittedBubblesFromSnapshot(snapshot);
const live = (snapshot.live_activity_events ?? [])
.map((entry, index) =>
agentChatBubbleFromLiveSessionActivity(entry, `live-${entry.key || index}`),
)
.filter((bubble): bubble is AgentChatBubble => Boolean(bubble));
return mergeAgentChatBubbles(committed, live);
}
function agentChatCommittedBubblesFromSnapshot(
snapshot: DashboardSnapshot | null,
): AgentChatBubble[] {
if (!snapshot) {
return [];
}
const historyItems = snapshot.activity_history?.items ?? [];
if (historyItems.length > 0) {
return agentChatBubblesFromActivityHistoryItems(historyItems, "activity");
}
return agentChatBubblesFromSessionActivities(snapshot.activity_events ?? [], "activity");
}
function agentChatBubblesFromHistoryPage(
page: DashboardActivityHistoryPage,
): AgentChatBubble[] {
return agentChatBubblesFromActivityHistoryItems(page.items ?? [], "history-page");
}
function agentChatBubblesFromActivityHistoryItems(
items: DashboardActivityHistoryItem[],
fallbackPrefix: string,
): AgentChatBubble[] {
return items
.map((item, index) =>
agentChatBubbleFromActivityHistoryItem(item, `${fallbackPrefix}-${index}`),
)
.filter((bubble): bubble is AgentChatBubble => Boolean(bubble));
}
function agentChatBubblesFromSessionActivities(
events: SessionActivityEvent[],
fallbackPrefix: string,
): AgentChatBubble[] {
return events
.map((event, index) =>
agentChatBubbleFromSessionActivity(event, `${fallbackPrefix}-${index}`),
)
.filter((bubble): bubble is AgentChatBubble => Boolean(bubble));
}
function agentChatBubbleFromLiveSessionActivity(
entry: { key: string; event: SessionActivityEvent } | unknown,
fallbackId: string,
): AgentChatBubble | null {
const record = asRecord(entry);
if (!record) {
return null;
}
const event = asSessionActivityEvent(record.event);
if (!event) {
return null;
}
const key = stringValue(record.key, fallbackId);
return agentChatBubbleFromSessionActivity(event, `live-${key}`, true);
}
function agentChatBubbleFromSessionActivity(
event: SessionActivityEvent | unknown,
fallbackId: string,
live = false,
): AgentChatBubble | null {
const sessionActivityEvent = asSessionActivityEvent(event);
if (!sessionActivityEvent) {
return null;
}
return agentChatBubbleFromActivityItem(
agentChatActivityItemFromEvent(sessionActivityEvent, fallbackId, 0, 0, live),
fallbackId,
live,
);
}
function agentChatBubbleFromActivityHistoryItem(
item: DashboardActivityHistoryItem | unknown,
fallbackId: string,
): AgentChatBubble | null {
const record = asRecord(item);
if (!record) {
return null;
}
const event = asSessionActivityEvent(record.event);
if (!event) {
return null;
}
const id = stringValue(record.id, fallbackId);
return agentChatBubbleFromActivityItem(
agentChatActivityItemFromEvent(
event,
id,
numberValue(record.created_at, 0),
numberValue(record.updated_at, 0),
),
fallbackId,
);
}
function agentChatActivityItemFromEvent(
event: SessionActivityEvent,
id: string,
createdAt: number,
updatedAt: number,
live = false,
): AgentChatActivityItem {
const meta = agentChatActivityMetaFromEvent(event);
return {
id,
kind: meta.kind,
status: live ? "running" : "completed",
title: meta.title,
actor: meta.actor,
ui_hint: meta.uiHint,
created_at: createdAt,
updated_at: updatedAt,
blocks: [],
detail_blocks: [],
activityEvent: event,
};
}
function agentChatActivityMetaFromEvent(event: SessionActivityEvent): {
kind: string;
actor?: string;
title: string;
uiHint?: string;
} {
const user = agentChatSessionActivityPayload(event, "User");
if (user) {
return {
kind: "message",
actor: "user",
title: agentChatSourceTitle(stringValue(user.content, ""), "You"),
};
}
const assistant = agentChatSessionActivityPayload(event, "Assistant");
if (assistant) {
return {
kind: "message",
actor: "assistant",
title: agentChatSourceTitle(stringValue(assistant.content, ""), "Agent"),
};
}
if (agentChatSessionActivityPayload(event, "Thinking")) {
return { kind: "message", actor: "assistant", title: "Thinking" };
}
const plan = agentChatSessionActivityPayload(event, "PlanResult");
if (plan) {
return { kind: "plan", actor: "system", title: "Updated Plan" };
}
const reply = agentChatSessionActivityPayload(event, "Reply");
if (reply) {
return { kind: "message", actor: "assistant", title: "Reply" };
}
const patch = agentChatSessionActivityPayload(event, "Patch");
if (patch) {
return { kind: "patch", actor: "tool", title: stringValue(patch.summary_line, "Patch") };
}
const warning = agentChatSessionActivityPayload(event, "Warning");
if (warning) {
return { kind: "warning", actor: "system", title: stringValue(warning.title, "Warning") };
}
const error = agentChatSessionActivityPayload(event, "Error");
if (error) {
return { kind: "error", actor: "system", title: stringValue(error.title, "Error") };
}
const telegram = agentChatSessionActivityPayload(event, "Telegram");
if (telegram) {
return {
kind: "message",
actor: "telegram",
title: stringValue(telegram.title, "Telegram"),
};
}
const runtimeStatus = agentChatSessionActivityPayload(event, "RuntimeStatus");
if (runtimeStatus) {
return {
kind: "tool",
actor: "system",
title: stringValue(runtimeStatus.label, "Working"),
};
}
const title =
stringValue(agentChatSessionActivityPayload(event, "GenericApp")?.title, "") ||
stringValue(agentChatSessionActivityPayload(event, "TerminalWait")?.title, "") ||
stringValue(agentChatSessionActivityPayload(event, "ExecResult")?.title, "") ||
stringValue(agentChatSessionActivityPayload(event, "LiveExec")?.title, "") ||
stringValue(agentChatSessionActivityPayload(event, "Browser")?.title, "") ||
stringValue(agentChatSessionActivityPayload(event, "LiveBrowser")?.title, "") ||
"Activity";
return { kind: "tool", actor: "tool", title };
}
function mergeAgentChatBubbles(
...groups: AgentChatBubble[][]
): AgentChatBubble[] {
const merged = new Map<string, AgentChatBubble>();
for (const bubble of groups.flat()) {
merged.set(bubble.id, bubble);
}
return Array.from(merged.values());
}
function agentChatBubbleIsOutputBoundary(bubble: AgentChatBubble) {
return agentChatBubbleHasSessionActivityEvent(bubble, "Reply");
}
function agentChatBubbleHasSessionActivityEvent(
bubble: AgentChatBubble,
variant: string,
) {
return Boolean(agentChatSessionActivityPayload(bubble.activityEvent, variant));
}
function formatAgentChatWorkedDuration(
bubbles: AgentChatBubble[],
outputBubble: AgentChatBubble,
) {
const reply = agentChatSessionActivityPayload(outputBubble.activityEvent, "Reply");
const elapsedSeconds = nullableNumberValue(reply?.elapsed_seconds);
if (elapsedSeconds !== null) {
return formatAgentChatDuration(elapsedSeconds * 1000);
}
const startTimes = bubbles
.map((bubble) => bubble.createdAt)
.filter((value) => value > 0);
const endTimes = bubbles
.map((bubble) => bubble.updatedAt)
.filter((value) => value > 0);
if (startTimes.length === 0 || endTimes.length === 0) {
return "0s";
}
return formatAgentChatDuration(
Math.max(0, Math.max(...endTimes) - Math.min(...startTimes)),
);
}
function formatAgentChatDuration(durationMs: number) {
const totalSeconds = Math.max(0, Math.round(durationMs / 1000));
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const totalMinutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (totalMinutes < 60) {
return seconds > 0 ? `${totalMinutes}m ${seconds}s` : `${totalMinutes}m`;
}
const totalHours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (totalHours < 24) {
return minutes > 0 ? `${totalHours}h ${minutes}m` : `${totalHours}h`;
}
const days = Math.floor(totalHours / 24);
const hours = totalHours % 24;
return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
}
function agentChatBubbleFromActivityItem(
item: AgentChatActivityItem | unknown,
fallbackId: string,
live = false,
): AgentChatBubble | null {
const record = asRecord(item);
if (!record) {
return null;
}
const id = stringValue(record.id, fallbackId);
const kind = stringValue(record.kind, "unknown");
const status = live ? "running" : stringValue(record.status, "unknown");
const actor = stringValue(record.actor, "");
const title = stringValue(record.title, agentChatFallbackTitle(actor, kind));
const tool = asRecord(record.tool);
const source = asRecord(record.source);
return {
id,
role: agentChatRoleFromActivityItem(actor, kind, source),
kind,
status,
uiHint: nullableStringValue(record.ui_hint),
title,
createdAt: numberValue(record.created_at, 0),
updatedAt: numberValue(record.updated_at, 0),
blocks: agentChatBlocksValue(record.blocks),
planSteps: agentChatPlanStepsFromMetadata(record.metadata),
live,
toolName: tool ? stringValue(tool.name, "") : undefined,
appName: tool ? stringValue(tool.app, "") : undefined,
sourceLabel: source
? stringValue(source.label, stringValue(source.source_type, ""))
: undefined,
activityEvent: asSessionActivityEvent(record.activityEvent),
};
}
function agentChatRoleFromActivityItem(
actor: string,
kind: string,
source: Record<string, unknown> | null,
): AgentChatBubbleRole {
if (actor === "assistant") {
return "assistant";
}
if (actor === "user") {
return "user";
}
if (
actor === "telegram" ||
stringValue(source?.source_type, "") === "telegram"
) {
return "telegram";
}
if (["plan", "primitive", "memory"].includes(kind) || actor === "system") {
return "system";
}
return "tool";
}
function agentChatFallbackTitle(actor: string, kind: string) {
if (actor === "assistant") {
return "Agent";
}
if (actor === "user") {
return "You";
}
if (actor === "telegram") {
return "Telegram";
}
if (kind === "plan") {
return "Plan";
}
if (kind === "primitive") {
return "Primitive";
}
return "Activity";
}
function agentChatActivityIconForBubble(
bubble: AgentChatBubble,
): AgentChatActivityMarkerKind {
if (bubble.kind === "error" || bubble.status === "failed") {
return "error";
}
return "activity";
}
function agentChatActivityIconClass(bubble: AgentChatBubble) {
if (bubble.status === "failed" || bubble.kind === "error") {
return "text-destructive";
}
if (bubble.live || bubble.status === "running") {
return "text-primary";
}
if (bubble.kind === "patch") {
return "text-primary";
}
return "text-muted-foreground";
}
function agentChatActivityStatusText(status: string, live?: boolean) {
if (live || status === "running") {
return "Running";
}
if (status === "failed") {
return "Failed";
}
if (status === "dismissed") {
return "Dismissed";
}
return status || "activity";
}
function agentChatActivityStatusClass(status: string, live?: boolean) {
if (live || status === "running") {
return "text-primary";
}
if (status === "failed") {
return "text-destructive";
}
return "text-muted-foreground";
}
function agentChatActivitySubtitle(bubble: AgentChatBubble) {
const primaryLabel =
bubble.appName === "Coding" || bubble.toolName === "explored"
? null
: bubble.appName || bubble.toolName || bubble.kind;
return [primaryLabel, bubble.sourceLabel].filter(Boolean).join(" · ");
}
function agentChatBubbleIsConversationMessage(bubble: AgentChatBubble) {
return (
bubble.kind === "message" &&
(bubble.role === "assistant" ||
bubble.role === "user" ||
bubble.role === "telegram")
);
}
function agentChatBubbleIsUserInput(bubble: AgentChatBubble) {
return (
bubble.kind === "message" &&
(bubble.role === "user" || bubble.role === "telegram") &&
!agentChatBubbleHasSessionActivityEvent(bubble, "Assistant") &&
!agentChatBubbleHasSessionActivityEvent(bubble, "Reply") &&
!agentChatBubbleHasSessionActivityEvent(bubble, "Thinking")
);
}
function agentChatFoldDisplayItemQuickNavTargetId(
item: AgentChatFoldDisplayItem<AgentChatBubble>,
) {
if (item.inputBoundaryId) {
return item.inputBoundaryId;
}
if (item.kind === "bubble" && agentChatBubbleIsUserInput(item.bubble)) {
return item.id;
}
return null;
}
function agentChatQuickNavLabelForBubble(bubble: AgentChatBubble) {
if (!agentChatBubbleIsUserInput(bubble)) {
return null;
}
const activityEvent = bubble.activityEvent;
const user = agentChatSessionActivityPayload(activityEvent, "User");
if (user) {
return agentChatQuickNavLabelFromPayload(user, bubble.title);
}
const telegram = agentChatSessionActivityPayload(activityEvent, "Telegram");
if (telegram) {
return agentChatQuickNavLabelFromPayload(telegram, bubble.title);
}
return agentChatQuickNavNormalizeLabel(
agentChatQuickNavLabelFromBlocks(bubble.blocks) ?? bubble.title,
);
}
function agentChatQuickNavOrderForBubble(bubble: AgentChatBubble) {
const historySequence = agentChatHistorySequenceFromId(bubble.id);
if (historySequence !== null) {
return historySequence;
}
if (Number.isFinite(bubble.createdAt) && bubble.createdAt > 0) {
return bubble.createdAt;
}
if (Number.isFinite(bubble.updatedAt) && bubble.updatedAt > 0) {
return bubble.updatedAt;
}
return Number.MAX_SAFE_INTEGER;
}
function agentChatQuickNavItemCompare(
left: AgentChatQuickNavItem,
right: AgentChatQuickNavItem,
) {
if (left.order !== right.order) {
return left.order - right.order;
}
return left.id.localeCompare(right.id);
}
function agentChatHistorySequenceFromId(id: string) {
const match = /^history-(\d+)$/.exec(id);
if (!match) {
return null;
}
const sequence = Number(match[1]);
return Number.isFinite(sequence) ? sequence : null;
}
function agentChatQuickNavLabelFromPayload(
payload: Record<string, unknown>,
fallback: string,
) {
const candidates = [
nullableStringValue(payload.content),
nullableStringValue(payload.full_body),
...stringArrayValue(payload.message_lines),
nullableStringValue(payload.title),
...stringArrayValue(payload.body_lines),
fallback,
];
for (const candidate of candidates) {
const label = agentChatQuickNavNormalizeLabel(candidate);
if (label) {
return label;
}
}
return null;
}
function agentChatQuickNavLabelFromBlocks(blocks: AgentChatBlock[]) {
for (const block of blocks) {
const record = asRecord(block);
if (record?.type !== "text") {
continue;
}
const label = agentChatQuickNavNormalizeLabel(nullableStringValue(record.text));
if (label) {
return label;
}
}
return null;
}
function agentChatQuickNavNormalizeLabel(value: string | null | undefined) {
if (!value) {
return null;
}
const firstLine = value.split(/\r?\n/).find((line) => line.trim());
if (!firstLine) {
return null;
}
const normalized = firstLine.replace(/\s+/g, " ").trim();
if (!normalized) {
return null;
}
return normalized.length > 180
? `${normalized.slice(0, 177).trimEnd()}...`
: normalized;
}
function agentChatQuickNavCollapsedItems(
items: AgentChatQuickNavItem[],
activeItemId: string | null,
) {
if (items.length <= AGENT_CHAT_QUICK_NAV_COLLAPSED_ITEM_LIMIT) {
return items;
}
const lastIndex = items.length - 1;
const activeIndex = activeItemId
? items.findIndex((item) => item.id === activeItemId)
: -1;
const indexes = new Set<number>();
for (let index = 0; index < AGENT_CHAT_QUICK_NAV_COLLAPSED_ITEM_LIMIT; index += 1) {
indexes.add(
Math.round(
(index * lastIndex) / (AGENT_CHAT_QUICK_NAV_COLLAPSED_ITEM_LIMIT - 1),
),
);
}
if (activeIndex > -1 && !indexes.has(activeIndex)) {
const sortedIndexes = Array.from(indexes).sort((left, right) => left - right);
const replaceableIndexes = sortedIndexes.filter(
(index) => index !== 0 && index !== lastIndex,
);
const replaceIndex = replaceableIndexes.reduce(
(nearest, index) =>
Math.abs(index - activeIndex) < Math.abs(nearest - activeIndex)
? index
: nearest,
replaceableIndexes[0] ?? sortedIndexes[0],
);
indexes.delete(replaceIndex);
indexes.add(activeIndex);
}
return Array.from(indexes)
.sort((left, right) => left - right)
.map((index) => items[index])
.filter((item): item is AgentChatQuickNavItem => Boolean(item));
}
function agentChatDisplayBlocksForBubble(
bubble: AgentChatBubble,
blocks: AgentChatBlock[],
): AgentChatBlock[] {
if (!agentChatBubbleIsConversationMessage(bubble)) {
return blocks;
}
return blocks;
}
function agentChatSessionActivityRenderForBubble(
bubble: AgentChatBubble,
): AgentChatSessionActivityRender | null {
const activityEvent = bubble.activityEvent;
const assistant = agentChatSessionActivityPayload(activityEvent, "Assistant");
if (assistant) {
return agentChatMessageActivityRender("activity", assistant, "Activity");
}
const user = agentChatSessionActivityPayload(activityEvent, "User");
if (user) {
const render = agentChatMessageActivityRender("user", user, "user");
render.imageAttachments = imageAttachmentsValue(user.image_attachments);
return render;
}
const browser = agentChatSessionActivityPayload(activityEvent, "Browser");
if (browser) {
return {
kind: "browser",
icon: "activity",
title: `Captured URL: ${compactAgentChatBrowserUrl(nullableStringValue(browser.url) ?? "unknown")}`,
detailLines: agentChatBrowserStatsLines(browser),
};
}
const liveBrowser = agentChatSessionActivityPayload(activityEvent, "LiveBrowser");
if (liveBrowser) {
const url = nullableStringValue(liveBrowser.url);
return {
kind: "browser",
icon: "activity",
title: url
? `Opening URL: ${compactAgentChatBrowserUrl(url)}`
: stringValue(liveBrowser.title, "Browser action"),
detailLines: stringArrayValue(liveBrowser.body_lines),
detailLimit: 1,
};
}
const webSearch = agentChatSessionActivityPayload(activityEvent, "WebSearch");
if (webSearch) {
const action = stringValue(webSearch.action, "searched").toLowerCase();
const url = nullableStringValue(webSearch.url);
const detailLines = [url, ...stringArrayValue(webSearch.body_lines)].filter(
(line): line is string => Boolean(line?.trim()),
);
return {
kind: "browser",
icon: "activity",
title: `${action === "searching" ? "Searching" : "Searched"} the web: ${stringValue(webSearch.query, "")}`,
detailLines,
};
}
const codingOpenProject = agentChatSessionActivityPayload(
activityEvent,
"CodingOpenProject",
);
if (codingOpenProject) {
return {
kind: "text",
icon: "activity",
title: `Opened Project: ${stringValue(codingOpenProject.project_root, "unknown")}`,
bodyLines: stringArrayValuePreserveWhitespace(codingOpenProject.detail_lines),
};
}
const codingReview = agentChatSessionActivityPayload(activityEvent, "CodingReview");
if (codingReview) {
const title = stringValue(codingReview.title, "Review").trim();
return {
kind: "text",
icon: "activity",
title: title || "Review",
bodyLines: [],
};
}
const genericApp = agentChatSessionActivityPayload(activityEvent, "GenericApp");
if (genericApp) {
return {
kind: "text",
icon: "activity",
title: `App: ${stringValue(genericApp.title, "Tool")}`,
bodyLines: [],
};
}
const plan = agentChatSessionActivityPayload(activityEvent, "PlanResult");
if (plan) {
return {
kind: "plan",
icon: "activity",
title: agentChatPlanTitleFromSessionActivity(plan),
steps: agentChatPlanStepsFromSessionActivity(activityEvent),
};
}
const createPrimitive = agentChatSessionActivityPayload(
activityEvent,
"CreatePrimitiveSpecResult",
);
if (createPrimitive) {
return {
kind: "primitive",
icon: "activity",
title: "Created Primitive Spec:",
primitiveId: stringValue(createPrimitive.primitive_id, "unknown"),
};
}
const activatePrimitive = agentChatSessionActivityPayload(
activityEvent,
"ActivatePrimitiveResult",
);
if (activatePrimitive) {
return {
kind: "primitive",
icon: "activity",
title: "Activated Primitive:",
primitiveId: stringValue(activatePrimitive.primitive_id, "unknown"),
};
}
const explored = agentChatSessionActivityPayload(activityEvent, "Explored");
if (explored) {
return {
kind: "explored",
icon: "activity",
title: stringValue(explored.title, "Explored"),
calls: agentChatExploredCallsFromSessionActivity(explored),
};
}
const execResult = agentChatSessionActivityPayload(activityEvent, "ExecResult");
if (execResult) {
return {
kind: "exec",
icon: "activity",
title: stringValue(execResult.title, "Command"),
outputLines: stringArrayValuePreserveWhitespace(execResult.output_lines),
exitCode: parseAgentChatExitCode(nullableStringValue(execResult.meta)),
};
}
const liveExec = agentChatSessionActivityPayload(activityEvent, "LiveExec");
if (liveExec) {
return {
kind: "exec",
icon: "activity",
title: stringValue(liveExec.title, "Tool running"),
outputLines: stringArrayValuePreserveWhitespace(liveExec.output_lines),
running: true,
exitCode: null,
};
}
const codingEdit = agentChatSessionActivityPayload(activityEvent, "CodingEdit");
if (codingEdit) {
const files = agentChatCodingEditFilesFromSessionActivity(codingEdit);
return {
kind: "patch",
icon: "activity",
title: agentChatCodingEditTitle(codingEdit, files),
files,
};
}
const patch = agentChatSessionActivityPayload(activityEvent, "Patch");
if (patch) {
const files = agentChatPatchFilesFromSessionActivity(patch);
return {
kind: "patch",
icon: "activity",
title: agentChatPatchTitle(files),
files,
};
}
const telegram = agentChatSessionActivityPayload(activityEvent, "Telegram");
if (telegram) {
return {
kind: "messageActivity",
icon: "activity",
title: stringValue(telegram.title, "Telegram"),
detailLines: stringArrayValue(telegram.detail_lines),
messageLines: stringArrayValuePreserveWhitespace(telegram.message_lines),
detailLimit: AGENT_CHAT_TELEGRAM_DETAIL_LIMIT,
messageLimit: AGENT_CHAT_TELEGRAM_MESSAGE_LIMIT,
};
}
const reply = agentChatSessionActivityPayload(activityEvent, "Reply");
if (reply) {
const disposition = normalizeAgentChatReplyDisposition(reply.disposition);
return {
kind: "reply",
title: agentChatReplyTitle(
disposition,
stringValue(reply.subject, "message"),
),
messageLines: stringArrayValuePreserveWhitespace(reply.message_lines),
disposition,
subject: stringValue(reply.subject, "message").toLowerCase(),
};
}
const terminalWait = agentChatSessionActivityPayload(activityEvent, "TerminalWait");
if (terminalWait) {
return {
kind: "text",
icon: "activity",
title: stringValue(terminalWait.title, "Terminal wait"),
bodyLines: stringArrayValuePreserveWhitespace(terminalWait.body_lines),
bodyLimit: AGENT_CHAT_TERMINAL_WAIT_LINE_LIMIT,
};
}
const warning = agentChatSessionActivityPayload(activityEvent, "Warning");
if (warning) {
return {
kind: "text",
icon: "activity",
title: stringValue(warning.title, "Warning"),
bodyLines: stringArrayValuePreserveWhitespace(warning.body_lines),
bodyLimit: AGENT_CHAT_ERROR_LINE_LIMIT,
tone: "muted",
};
}
const error = agentChatSessionActivityPayload(activityEvent, "Error");
if (error) {
return {
kind: "text",
icon: "error",
title: stringValue(error.title, "Error"),
bodyLines: stringArrayValuePreserveWhitespace(error.body_lines),
bodyLimit: AGENT_CHAT_ERROR_LINE_LIMIT,
tone: "error",
preserveSoftBreaks: true,
};
}
const thinking = agentChatSessionActivityPayload(activityEvent, "Thinking");
if (thinking) {
return {
kind: "thinking",
content: stringValue(thinking.content, ""),
bodyLimit: AGENT_CHAT_THINKING_PREVIEW_LINE_LIMIT,
};
}
const runtimeStatus = agentChatSessionActivityPayload(activityEvent, "RuntimeStatus");
if (runtimeStatus) {
return {
kind: "runtimeStatus",
icon: "activity",
title: stringValue(runtimeStatus.label, "Working"),
detail: nullableStringValue(runtimeStatus.detail),
startedAtMs: nullableNumberValue(runtimeStatus.active_runtime_started_at_ms),
reducedMotion: nullableStringValue(runtimeStatus.reduced_motion),
};
}
const workflow = agentChatSessionActivityPayload(activityEvent, "Workflow");
if (workflow) {
return {
kind: "workflow",
icon: "activity",
workflowId: stringValue(workflow.workflow_id, "unknown"),
status: stringValue(workflow.status, "Failed"),
output: workflow.output,
message: stringValue(workflow.message, "workflow completed"),
snapshot: asWorkflowRunSnapshot(workflow.snapshot),
};
}
return null;
}
function agentChatMessageActivityRender(
icon: AgentChatActivityMarkerKind,
payload: Record<string, unknown>,
fallbackTitle: string,
): Extract<AgentChatSessionActivityRender, { kind: "text" }> {
const content = stringValue(payload.content, "");
return {
kind: "text",
icon,
title: agentChatSourceTitle(content, fallbackTitle),
bodyLines: agentChatSourceBodyLines(content),
fullBody: content.trim() ? content : null,
};
}
function agentChatSourceTitle(content: string, fallback: string) {
return (
content
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? fallback
);
}
function agentChatSourceBodyLines(content: string) {
const lines = content.split(/\r?\n/);
if (lines.length > 0) {
lines.shift();
}
while (lines[0]?.trim() === "") {
lines.shift();
}
while (lines.at(-1)?.trim() === "") {
lines.pop();
}
return lines;
}
function agentChatAgentMessageFromLines(messageLines: string[]) {
if (messageLines.length === 0) {
return null;
}
const fullBody = messageLines.join("\n").replace(/[\r\n]+$/, "");
const lines = fullBody.split("\n");
const title = lines[0] ?? "";
return {
title,
bodyLines: lines.slice(1),
fullBody,
};
}
function agentChatExploredCallsFromSessionActivity(
payload: Record<string, unknown>,
): AgentChatExploredCall[] {
return arrayValue(payload.calls)
.map(asRecord)
.filter((call): call is Record<string, unknown> => Boolean(call))
.map((call) => ({
toolName: stringValue(call.tool_name, "tool"),
action: normalizeAgentChatExploredAction(call.action),
target: nullableStringValue(call.target),
secondaryTarget: nullableStringValue(call.secondary_target),
summary: stringValue(call.summary, ""),
detailLines: stringArrayValue(call.detail_lines),
detailTitle: nullableStringValue(call.detail_title),
}));
}
function normalizeAgentChatExploredAction(
value: unknown,
): AgentChatExploredCallAction {
if (typeof value !== "string") {
return "unknown";
}
const normalized = value.toLowerCase();
if (
normalized === "read" ||
normalized === "list" ||
normalized === "search" ||
normalized === "run"
) {
return normalized;
}
return "unknown";
}
function agentChatExploredActionLabel(action: AgentChatExploredCallAction) {
if (action === "read") {
return "Read";
}
if (action === "list") {
return "List";
}
if (action === "search") {
return "Search";
}
if (action === "run") {
return "Run";
}
return "Tool";
}
function agentChatExploredDetailRows(calls: AgentChatExploredCall[]) {
const rows: ReactNode[] = [];
let index = 0;
while (index < calls.length) {
const call = calls[index];
if (call.action === "read") {
const names = [agentChatExploredReadTarget(call)];
index += 1;
while (index < calls.length && calls[index].action === "read") {
names.push(agentChatExploredReadTarget(calls[index]));
index += 1;
}
rows.push(
<AgentChatExploredActionLine
action="Read"
detail={dedupeStrings(names).join(", ")}
/>,
);
continue;
}
rows.push(
<AgentChatExploredActionLine
action={agentChatExploredActionLabel(call.action)}
detail={agentChatExploredCallDetail(call)}
/>,
);
index += 1;
}
return rows;
}
function AgentChatExploredActionLine({
action,
detail,
}: {
action: string;
detail: string;
}) {
return (
<p className="min-w-0 break-words text-foreground/90">
<span className="font-medium text-primary">{action}</span>
{detail ? <span> {detail}</span> : null}
</p>
);
}
function agentChatExploredCallDetail(call: AgentChatExploredCall) {
if (call.action === "search") {
if (call.target) {
return call.secondaryTarget
? `${call.target.trim()} in ${compactAgentChatCodingSummaryPath(call.secondaryTarget)}`
: call.target.trim();
}
return call.summary;
}
if (call.action === "list") {
return call.target
? compactAgentChatCodingSummaryPath(call.target)
: call.summary;
}
if (call.action === "run") {
return call.target?.trim() || call.summary;
}
return call.summary;
}
function agentChatExploredReadTarget(call: AgentChatExploredCall) {
return compactAgentChatCodingSummaryPath(call.target || call.summary);
}
function compactAgentChatCodingSummaryPath(value: string) {
const target = value
.split(" -> ")[0]
.split(":L")[0]
.split("#")[0]
.trim();
const normalized = target.replace(/\\/g, "/");
const parts = normalized.split("/").filter(Boolean);
return parts.at(-1) ?? target;
}
function dedupeStrings(values: string[]) {
return values.filter((value, index) => value && values.indexOf(value) === index);
}
function agentChatBrowserStatsLines(payload: Record<string, unknown>): string[] {
const lineCount = nullableNumberValue(payload.line_count);
const refCount = nullableNumberValue(payload.ref_count);
const stats = [
lineCount !== null ? `${lineCount} lines` : null,
refCount !== null ? `${refCount} refs` : null,
].filter((line): line is string => Boolean(line));
return stats.length > 0 ? [stats.join(" · ")] : [];
}
function compactAgentChatBrowserUrl(value: string) {
const compact = value.replace(/\s+/g, " ").trim();
return compact.length > 88 ? `${compact.slice(0, 85)}...` : compact;
}
function agentChatPatchFilesFromSessionActivity(
payload: Record<string, unknown>,
): AgentChatDiffFile[] {
return agentChatPatchFilesFromValue(payload.files);
}
function agentChatCodingEditFilesFromSessionActivity(
payload: Record<string, unknown>,
): AgentChatDiffFile[] {
return agentChatPatchFilesFromValue(payload.diff_files);
}
function agentChatPatchFilesFromValue(value: unknown): AgentChatDiffFile[] {
return diffFilesValue(
arrayValue(value).map((file) => {
const record = asRecord(file);
return record
? {
...record,
lines: record.diff_lines,
}
: file;
}),
);
}
function agentChatCodingEditTitle(
payload: Record<string, unknown>,
files: AgentChatDiffFile[],
) {
if (files.length > 0) {
return agentChatPatchTitle(files);
}
const file = nullableStringValue(payload.file);
const addedLines = numberValue(payload.added_lines, 0);
const removedLines = numberValue(payload.removed_lines, 0);
if (file) {
return `Edited ${file} (+${addedLines} -${removedLines})`;
}
return `Edited Code (+${addedLines} -${removedLines})`;
}
function agentChatPatchTitle(files: AgentChatDiffFile[]) {
if (files.length === 1) {
const file = files[0];
return `Edited ${file.path} (+${file.added_lines} -${file.removed_lines})`;
}
const fileNoun = files.length === 1 ? "File" : "Files";
return `Edited ${files.length} ${fileNoun}`;
}
function agentChatPlanTitleFromSessionActivity(payload: Record<string, unknown>) {
return stringValue(payload.kind, "updated").toLowerCase() === "proposed"
? "Proposed Plan"
: "Updated Plan";
}
function normalizeAgentChatReplyDisposition(value: unknown) {
const normalized = typeof value === "string" ? value.toLowerCase() : "";
if (
normalized === "resolved" ||
normalized === "dismissed" ||
normalized === "failed"
) {
return normalized;
}
return "unknown";
}
function agentChatReplyTitle(disposition: string, subject: string) {
if (disposition === "resolved") {
return subject.toLowerCase() === "notice"
? "Resolved Notice"
: "Resolved Message";
}
if (disposition === "dismissed") {
return "Dismissed";
}
if (disposition === "failed") {
return "Failed";
}
return "Reply";
}
function parseAgentChatExitCode(meta: string | null) {
const match = meta?.match(/exit=(-?\d+)/);
return match ? Number(match[1]) : null;
}
function truncateAgentChatLinesMiddle(
lines: string[],
headCount: number,
tailCount: number,
) {
if (lines.length <= headCount + tailCount) {
return lines;
}
const hiddenCount = lines.length - headCount - tailCount;
return [
...lines.slice(0, headCount),
`… +${hiddenCount} more line(s)`,
...lines.slice(lines.length - tailCount),
];
}
function agentChatDiffLineNumberWidth(
lines: AgentChatDiffLine[],
key: "old_lineno" | "new_lineno",
) {
return Math.max(
1,
...lines.map((line) =>
typeof line[key] === "number" ? String(line[key]).length : 0,
),
);
}
function agentChatPlanStepsFromSessionActivity(
event: SessionActivityEvent | null | undefined,
): AgentChatPlanStep[] {
const plan = agentChatSessionActivityPayload(event, "PlanResult");
if (!plan) {
return [];
}
return arrayValue(plan.steps)
.map(asRecord)
.filter((step): step is Record<string, unknown> => Boolean(step))
.map((step) => {
const text = stringValue(step.text, "");
if (!text) {
return null;
}
return {
status: normalizeCanonicalPlanStepStatus(step.status),
text,
} satisfies AgentChatPlanStep;
})
.filter((step): step is AgentChatPlanStep => Boolean(step));
}
function agentChatSessionActivityPayload(
event: SessionActivityEvent | null | undefined,
variant: string,
): Record<string, unknown> | null {
const record = asSessionActivityEvent(event) as Record<string, unknown> | null;
return asRecord(record?.[variant]);
}
function normalizeCanonicalPlanStepStatus(
value: unknown,
): AgentChatPlanStepStatus {
if (value === "Pending" || value === "pending") {
return "pending";
}
if (value === "InProgress" || value === "in_progress") {
return "in_progress";
}
if (value === "Completed" || value === "completed") {
return "completed";
}
return "unknown";
}
function agentChatPlanStepsFromMetadata(value: unknown): AgentChatPlanStep[] {
const metadata = asRecord(value);
const steps = Array.isArray(metadata?.steps) ? metadata.steps : [];
return steps
.map((entry) => {
const record = asRecord(entry);
if (!record) {
return null;
}
const text = stringValue(record.text, "");
if (!text) {
return null;
}
return {
status: normalizeAgentChatPlanStepStatus(record.status) ?? "unknown",
text,
} satisfies AgentChatPlanStep;
})
.filter((step): step is AgentChatPlanStep => Boolean(step));
}
function normalizeAgentChatPlanStepStatus(
value: unknown,
): AgentChatPlanStepStatus | null {
return value === "pending" ||
value === "in_progress" ||
value === "completed" ||
value === "unknown"
? value
: null;
}
type AgentChatDiffFile = {
path: string;
operation: string;
added_lines: number;
removed_lines: number;
lines: AgentChatDiffLine[];
};
type AgentChatDiffLine = {
kind: string;
text: string;
old_lineno?: number | null;
new_lineno?: number | null;
};
function agentChatBlocksValue(value: unknown): AgentChatBlock[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((block): block is AgentChatBlock => {
const record = asRecord(block);
return Boolean(record && typeof record.type === "string");
});
}
function asWorkflowRunSnapshot(value: unknown): WorkflowRunSnapshot | null {
const snapshot = asRecord(value);
if (!snapshot) {
return null;
}
const runId = nullableStringValue(snapshot.run_id);
const workflowId = nullableStringValue(snapshot.workflow_id);
const status = nullableStringValue(snapshot.status);
const startedAtMs = nullableNumberValue(snapshot.started_at_ms);
if (!runId || !workflowId || !status || startedAtMs === null) {
return null;
}
const awaitGroups = arrayValue(snapshot.await_groups)
.map(asRecord)
.filter((group): group is Record<string, unknown> => Boolean(group))
.map((group): WorkflowAwaitGroupSnapshot | null => {
const groupId = nullableStringValue(group.group_id);
if (!groupId) {
return null;
}
return {
group_id: groupId,
sequence: numberValue(group.sequence, 0),
status: stringValue(group.status, "pending") as WorkflowNodeStatus,
started_at_ms: numberValue(group.started_at_ms, startedAtMs),
completed_at_ms: nullableNumberValue(group.completed_at_ms),
worker_ids: stringArrayValue(group.worker_ids),
};
})
.filter((group): group is WorkflowAwaitGroupSnapshot => Boolean(group));
const workers = arrayValue(snapshot.workers)
.map(asRecord)
.filter((worker): worker is Record<string, unknown> => Boolean(worker))
.map((worker): WorkflowWorkerSnapshot | null => {
const workerId = nullableStringValue(worker.worker_id);
const awaitGroupId = nullableStringValue(worker.await_group_id);
if (!workerId || !awaitGroupId) {
return null;
}
return {
worker_id: workerId,
actor_id: nullableStringValue(worker.actor_id) ?? undefined,
await_group_id: awaitGroupId,
role: stringValue(worker.role, "agent"),
model: stringValue(worker.model, "main"),
status: stringValue(worker.status, "pending") as WorkflowNodeStatus,
started_at_ms: numberValue(worker.started_at_ms, startedAtMs),
completed_at_ms: nullableNumberValue(worker.completed_at_ms),
agent_run_time_ms: Math.max(numberValue(worker.agent_run_time_ms, 0), 0),
input: worker.input,
output: worker.output,
error: nullableStringValue(worker.error),
activity_count: Math.max(
numberValue(worker.activity_count, 0),
arrayValue(worker.activity).length,
),
activity_revision: numberValue(worker.activity_revision, 0),
activity: arrayValue(worker.activity)
.map(asSessionActivityEvent)
.filter((event): event is SessionActivityEvent => Boolean(event)),
};
})
.filter((worker): worker is WorkflowWorkerSnapshot => Boolean(worker));
const transitions = arrayValue(snapshot.transitions)
.map(asRecord)
.filter((transition): transition is Record<string, unknown> => Boolean(transition))
.map((transition): WorkflowTransitionSnapshot | null => {
const sourceWorkerId = nullableStringValue(transition.source_worker_id);
const targetWorkerId = nullableStringValue(transition.target_worker_id);
const kind = nullableStringValue(transition.kind);
if (!sourceWorkerId || !targetWorkerId || !kind) {
return null;
}
return {
source_worker_id: sourceWorkerId,
target_worker_id: targetWorkerId,
kind: kind as WorkflowTransitionKind,
};
})
.filter((transition): transition is WorkflowTransitionSnapshot => Boolean(transition));
return {
run_id: runId,
workflow_id: workflowId,
status: status as WorkflowNodeStatus,
started_at_ms: startedAtMs,
completed_at_ms: nullableNumberValue(snapshot.completed_at_ms),
input: snapshot.input,
output: snapshot.output,
error: nullableStringValue(snapshot.error),
await_groups: awaitGroups,
transitions,
workers,
};
}
function asSessionActivityEvent(value: unknown): SessionActivityEvent | null {
const record = asRecord(value);
if (!record) {
return null;
}
const keys = Object.keys(record);
if (keys.length !== 1) {
return null;
}
return record as SessionActivityEvent;
}
function arrayValue(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function imageAttachmentsValue(value: unknown): AgentChatImageAttachmentData[] {
return arrayValue(value)
.map(asRecord)
.filter((attachment): attachment is Record<string, unknown> =>
Boolean(attachment),
)
.map((attachment) => ({
label: stringValue(attachment.label, "Image"),
uri: stringValue(attachment.uri, ""),
mimeType: stringValue(attachment.mime_type, ""),
}))
.filter((attachment) => attachment.uri);
}
function kvEntriesValue(value: unknown) {
if (!Array.isArray(value)) {
return [];
}
return value
.map(asRecord)
.filter((entry): entry is Record<string, unknown> => Boolean(entry))
.map((entry) => ({
key: stringValue(entry.key, ""),
value: stringValue(entry.value, ""),
}))
.filter((entry) => entry.key || entry.value);
}
function diffFilesValue(value: unknown): AgentChatDiffFile[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map(asRecord)
.filter((file): file is Record<string, unknown> => Boolean(file))
.map((file) => ({
path: stringValue(file.path, "unknown"),
operation: stringValue(file.operation, "update"),
added_lines: numberValue(file.added_lines, 0),
removed_lines: numberValue(file.removed_lines, 0),
lines: diffLinesValue(file.lines),
}));
}
function diffLinesValue(value: unknown): AgentChatDiffLine[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map(asRecord)
.filter((line): line is Record<string, unknown> => Boolean(line))
.map((line) => ({
kind: stringValue(line.kind, "context"),
text: stringValue(line.text, ""),
old_lineno: nullableNumberValue(line.old_lineno),
new_lineno: nullableNumberValue(line.new_lineno),
}));
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function stringValue(value: unknown, fallback: string) {
return typeof value === "string" && value.trim() ? value : fallback;
}
function numberValue(value: unknown, fallback: number) {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function nullableNumberValue(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function stringArrayValue(value: unknown) {
if (!Array.isArray(value)) {
return [];
}
return value
.filter((line): line is string => typeof line === "string")
.map((line) => line.trim())
.filter(Boolean);
}
function stringArrayValuePreserveWhitespace(value: unknown) {
if (!Array.isArray(value)) {
return [];
}
return value.filter((line): line is string => typeof line === "string");
}
function nullableStringValue(value: unknown) {
return typeof value === "string" && value.trim() ? value : null;
}
function safeJsonPreview(value: unknown) {
try {
const text = JSON.stringify(value);
if (!text) {
return "unknown";
}
return text.length > 160 ? `${text.slice(0, 160)}…` : text;
} catch {
return "unknown";
}
}