import {
agentChatRuntimeErrorMessage,
type AgentChatRuntime,
} from "@agents24/chat-react/runtime"
import type { Agents24ChatScaffoldComponents } from "../../app"
import type { ThreadMessage } from "@agents24/client"
import { AgentChatQueue, queuedMessageComposerFiles, useLiveCompletionRuns, type AgentChatComposerFile } from "@agents24/chat-react/ui"
import { CircleAlert, LoaderCircle, SquarePen, X } from "lucide-react"
import * as React from "react"
import type { Agents24ChatIdentity } from "../../identity"
import { ChatComposer } from "./chat-composer"
import { ChatMessage } from "./chat-message"
import { SubagentTasks } from "./subagent-tasks"
import { ThemeMenu } from "./theme-menu"
import { ThreadSidebar } from "./thread-sidebar"
import { TurnOutline } from "./turn-outline"
import { isScaffoldTimelineLoading } from "./timeline-loading"
import { Button } from "../ui/button"
import {
LatestThreadScroller,
latestThreadAnchorId,
} from "@agents24/chat-react"
import { SidebarInset, SidebarProvider, SidebarTrigger, useSidebar } from "../ui/sidebar"
const LOADING_INPUT_CONTRACT = {
version: "agents24.runtime-inputs.v3" as const,
modalities: {
text: { enabled: false, required: false },
files: { enabled: false, required: false, allowed_mime_types: [] },
images: { enabled: false, required: false, allowed_mime_types: [] },
audio: { enabled: false, required: false, allowed_mime_types: [], recording_enabled: false },
},
limits: { max_attachments_per_turn: 0, max_file_bytes: 0, max_total_bytes: 0 },
retention: { mode: "thread_lifetime" as const },
}
function PersistentSidebarTrigger() {
return (
)
}
function CollapsedHeaderActions({ onNew }: { onNew: () => void }) {
const { isMobile, openMobile, state } = useSidebar()
const sidebarVisible = isMobile ? openMobile : state === "expanded"
if (sidebarVisible) return null
return (
<>
>
)
}
function OperationErrorNotice({ runtime }: { runtime: AgentChatRuntime }) {
const error = runtime.operationError || runtime.backgroundError
if (!error) return null
return (
{agentChatRuntimeErrorMessage(error) || "The chat could not complete that action."}
)
}
export function ChatShell({
components,
identity,
runtime,
sidebar,
}: {
components?: Agents24ChatScaffoldComponents
identity: Agents24ChatIdentity
runtime: AgentChatRuntime
sidebar?: React.ReactNode
}) {
const Composer = components?.Composer || ChatComposer
const { controller } = runtime
const isUploading = controller.runState === "uploading"
const isRunning =
controller.runState === "streaming"
|| controller.runState === "reconnecting"
|| controller.runState === "paused"
|| controller.runState === "cancelling"
const isCancelling = controller.runState === "cancelling"
const wasLiveRun = useLiveCompletionRuns(controller.activeRunId, isRunning)
const activeTitle = controller.threads.find((thread) => thread.id === controller.activeThreadId)?.title
const hasBlockingError = Boolean(runtime.fatalError && controller.messages.length === 0)
const isEmptyState = controller.activeThreadId === null && controller.messages.length === 0 && !hasBlockingError
const latestAnchorId = latestThreadAnchorId(controller.messages)
const composerOverlayRef = React.useRef(null)
const [composerOverlayHeight, setComposerOverlayHeight] = React.useState(192)
const [restoredDraft, setRestoredDraft] = React.useState<{ revision: number; text: string; files: AgentChatComposerFile[] } | null>(null)
const draftRevisionRef = React.useRef(0)
const retractForEdit = React.useCallback(async (message: ThreadMessage) => {
const draft = await controller.retractPendingMessage(message)
setRestoredDraft({ revision: ++draftRevisionRef.current, text: draft.text,
files: queuedMessageComposerFiles(draft),
})
}, [controller])
React.useLayoutEffect(() => {
const overlay = composerOverlayRef.current
if (!overlay || isEmptyState) return
const updateHeight = () => {
setComposerOverlayHeight(Math.ceil(overlay.getBoundingClientRect().height))
}
updateHeight()
const resizeObserver = new ResizeObserver(updateHeight)
resizeObserver.observe(overlay)
return () => resizeObserver.disconnect()
}, [isEmptyState])
return (
{sidebar || }
{!isEmptyState && (
{controller.hasOlderTurns && (
)}
{runtime.isBootstrapping && (
)}
{hasBlockingError && (
Couldn’t connect
{runtime.errorMessage}
)}
{controller.messages.map((message) => (
controller.regenerate(message)}
onSetFeedback={(input) => controller.setFeedback(message, input)}
resolveAttachmentContent={runtime.resolveAttachmentContent}
Timeline={components?.Timeline}
/>
))}
)}
{isEmptyState ?
Ready when you are.
:
}
}
restoredDraft={restoredDraft}
inputContract={runtime.inputContract || LOADING_INPUT_CONTRACT}
contextWindow={runtime.contextWindow}
disabled={runtime.isBootstrapping || runtime.isChangingAgent || isUploading || hasBlockingError}
scopeKey={controller.activeThreadId}
focusKey={controller.activeThreadId}
forceExpanded={isEmptyState}
isCancelling={isCancelling}
isRunning={isRunning}
agentId={runtime.agentId}
agents={runtime.agents}
onAgentChange={(agentId) => { void runtime.changeAgent(agentId) }}
onStop={() => { void controller.cancelRun().catch(() => undefined) }}
modelId={runtime.modelId}
models={runtime.models}
onModelChange={runtime.changeModel}
onSubmit={(text, files) => runtime.submit({
text,
files: files.filter((file) => file.source).map((file) => ({
data: file.source!,
mediaType: file.mediaType,
name: file.filename,
})),
attachmentIds: files.flatMap((file) => file.persistedAttachmentId ? [file.persistedAttachmentId] : []),
attachments: files,
})}
utilityRow={isEmptyState ? "inline" : "below"}
/>
)
}