From 432fcf3fa3bb3cc6af42902198a19ed38908a9c2 Mon Sep 17 00:00:00 2001 From: "Jean P.D. Meijer" Date: Thu, 28 May 2026 23:00:56 +0200 Subject: [PATCH 1/2] feat(app): add new message list --- .gitignore | 2 + apps/app/components.json | 8 +- apps/app/package.json | 6 + apps/app/scripts/artifacts.test.ts | 36 + apps/app/scripts/open-target.test.ts | 35 + apps/app/src/app/index.css | 2 +- .../ai-elements/chain-of-thought.tsx | 222 +++ .../app/src/components/chat/artifact-icon.tsx | 54 + apps/app/src/components/chat/artifact.tsx | 67 + .../components/chat/message-list-provider.tsx | 97 + apps/app/src/components/chat/message-list.tsx | 628 +++++++ .../src/components/chat/message-sources.tsx | 121 ++ .../src/components/chat/task-suggestions.tsx | 76 + apps/app/src/components/chat/utils.ts | 156 ++ .../app/src/components/descriptive-button.tsx | 58 + apps/app/src/components/markdown/markdown.tsx | 335 ++++ .../components/markdown/text-highlights.ts | 99 + apps/app/src/components/tools/apply-patch.tsx | 29 + apps/app/src/components/tools/bash.tsx | 39 + .../src/components/tools/collapsible-tool.tsx | 144 ++ apps/app/src/components/tools/edit.tsx | 27 + apps/app/src/components/tools/file.tsx | 228 +++ apps/app/src/components/tools/glob.tsx | 47 + apps/app/src/components/tools/grep.tsx | 47 + apps/app/src/components/tools/lsp.tsx | 62 + apps/app/src/components/tools/path.ts | 22 + apps/app/src/components/tools/question.tsx | 67 + apps/app/src/components/tools/skill.tsx | 26 + apps/app/src/components/tools/todowrite.tsx | 28 + apps/app/src/components/tools/webfetch.tsx | 68 + apps/app/src/components/tools/websearch.tsx | 56 + apps/app/src/components/ui/avatar.tsx | 107 ++ apps/app/src/components/ui/badge.tsx | 52 + .../src/components/ui/chain-of-thought.tsx | 148 ++ apps/app/src/components/ui/code-block.tsx | 103 + apps/app/src/components/ui/hover-card.tsx | 49 + apps/app/src/components/ui/image.tsx | 76 + apps/app/src/components/ui/markdown.tsx | 110 ++ apps/app/src/components/ui/message.tsx | 127 ++ apps/app/src/components/ui/reasoning.tsx | 174 ++ .../app/src/components/ui/response-stream.tsx | 394 ++++ apps/app/src/components/ui/source.tsx | 119 ++ apps/app/src/components/ui/steps.tsx | 120 ++ apps/app/src/components/ui/tool.tsx | 196 ++ apps/app/src/lib/artifacts.ts | 278 +++ apps/app/src/lib/build-in-tools.ts | 370 ++++ apps/app/src/lib/messages.ts | 1 + apps/app/src/lib/target-provider.ts | 43 + apps/app/src/lib/websearch-results.ts | 33 + .../domains/session/artifacts/open-target.ts | 13 + .../session/status/session-activity-store.ts | 51 +- .../session/surface/scroll-controller.ts | 35 +- .../session/surface/scroll-overlay.tsx | 93 + .../domains/session/surface/scroll-store.ts | 14 + .../session/surface/session-surface.tsx | 157 +- .../domains/session/sync/parse-tool-parts.ts | 78 + .../domains/session/sync/session-sync.ts | 64 +- .../domains/session/sync/usechat-adapter.ts | 85 +- .../app/tests/session-sync-tool-parts.test.ts | 165 ++ pnpm-lock.yaml | 1649 ++++++++++++++++- 60 files changed, 7568 insertions(+), 228 deletions(-) create mode 100644 apps/app/scripts/artifacts.test.ts create mode 100644 apps/app/src/components/ai-elements/chain-of-thought.tsx create mode 100644 apps/app/src/components/chat/artifact-icon.tsx create mode 100644 apps/app/src/components/chat/artifact.tsx create mode 100644 apps/app/src/components/chat/message-list-provider.tsx create mode 100644 apps/app/src/components/chat/message-list.tsx create mode 100644 apps/app/src/components/chat/message-sources.tsx create mode 100644 apps/app/src/components/chat/task-suggestions.tsx create mode 100644 apps/app/src/components/chat/utils.ts create mode 100644 apps/app/src/components/descriptive-button.tsx create mode 100644 apps/app/src/components/markdown/markdown.tsx create mode 100644 apps/app/src/components/markdown/text-highlights.ts create mode 100644 apps/app/src/components/tools/apply-patch.tsx create mode 100644 apps/app/src/components/tools/bash.tsx create mode 100644 apps/app/src/components/tools/collapsible-tool.tsx create mode 100644 apps/app/src/components/tools/edit.tsx create mode 100644 apps/app/src/components/tools/file.tsx create mode 100644 apps/app/src/components/tools/glob.tsx create mode 100644 apps/app/src/components/tools/grep.tsx create mode 100644 apps/app/src/components/tools/lsp.tsx create mode 100644 apps/app/src/components/tools/path.ts create mode 100644 apps/app/src/components/tools/question.tsx create mode 100644 apps/app/src/components/tools/skill.tsx create mode 100644 apps/app/src/components/tools/todowrite.tsx create mode 100644 apps/app/src/components/tools/webfetch.tsx create mode 100644 apps/app/src/components/tools/websearch.tsx create mode 100644 apps/app/src/components/ui/avatar.tsx create mode 100644 apps/app/src/components/ui/badge.tsx create mode 100644 apps/app/src/components/ui/chain-of-thought.tsx create mode 100644 apps/app/src/components/ui/code-block.tsx create mode 100644 apps/app/src/components/ui/hover-card.tsx create mode 100644 apps/app/src/components/ui/image.tsx create mode 100644 apps/app/src/components/ui/markdown.tsx create mode 100644 apps/app/src/components/ui/message.tsx create mode 100644 apps/app/src/components/ui/reasoning.tsx create mode 100644 apps/app/src/components/ui/response-stream.tsx create mode 100644 apps/app/src/components/ui/source.tsx create mode 100644 apps/app/src/components/ui/steps.tsx create mode 100644 apps/app/src/components/ui/tool.tsx create mode 100644 apps/app/src/lib/artifacts.ts create mode 100644 apps/app/src/lib/build-in-tools.ts create mode 100644 apps/app/src/lib/messages.ts create mode 100644 apps/app/src/lib/target-provider.ts create mode 100644 apps/app/src/lib/websearch-results.ts create mode 100644 apps/app/src/react-app/domains/session/surface/scroll-overlay.tsx create mode 100644 apps/app/src/react-app/domains/session/sync/parse-tool-parts.ts create mode 100644 apps/app/tests/session-sync-tool-parts.test.ts diff --git a/.gitignore b/.gitignore index 4225f91e20..929046422e 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,5 @@ opencode.jsonc .vercel .env*.local .claude/* +.security +transcript-analysis \ No newline at end of file diff --git a/apps/app/components.json b/apps/app/components.json index 3781d892e6..989c733406 100644 --- a/apps/app/components.json +++ b/apps/app/components.json @@ -12,6 +12,8 @@ }, "iconLibrary": "lucide", "rtl": true, + "menuColor": "inverted-translucent", + "menuAccent": "subtle", "aliases": { "components": "@/components", "utils": "@/lib/utils", @@ -19,7 +21,7 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "menuColor": "inverted-translucent", - "menuAccent": "subtle", - "registries": {} + "registries": { + "@ai-elements": "https://ai-sdk.dev/elements/api/registry/{name}.json" + } } diff --git a/apps/app/package.json b/apps/app/package.json index 1e5213c53f..76aadeafeb 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -55,6 +55,7 @@ "@openwork/ui": "workspace:*", "@paper-design/shaders-react": "0.0.72", "@radix-ui/colors": "^3.0.0", + "@radix-ui/react-use-controllable-state": "^1.2.2", "@shikijs/transformers": "^4.0.2", "@tanstack/react-query": "^5.90.3", "@tanstack/react-virtual": "^3.13.23", @@ -63,6 +64,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "dompurify": "^3.4.5", "emojilib": "^4.0.3", "fuzzysort": "^3.1.0", "jsonc-parser": "^3.2.1", @@ -74,10 +76,14 @@ "motion": "^12.38.0", "react": "catalog:", "react-dom": "catalog:", + "react-markdown": "^10.1.0", "react-resizable-panels": "^4.11.0", "react-router-dom": "^7.14.1", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", "shadcn": "^4.6.0", "shiki": "^4.0.2", + "streamdown": "^2.5.0", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", "xlsx": "^0.18.5", diff --git a/apps/app/scripts/artifacts.test.ts b/apps/app/scripts/artifacts.test.ts new file mode 100644 index 0000000000..a3ea9ec853 --- /dev/null +++ b/apps/app/scripts/artifacts.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "bun:test"; +import type { UIMessage } from "ai"; + +import type { OpenTarget } from "../src/react-app/domains/session/artifacts/open-target"; +import { getArtifactsFromMessages } from "../src/lib/artifacts"; + +describe("getArtifactsFromMessages", () => { + it("uses verified relative targets for absolute attachment paths", () => { + const messages: UIMessage[] = [{ + id: "msg_attachment", + role: "assistant", + parts: [{ + type: "source-document", + sourceId: "attachment-source", + mediaType: "text/csv", + title: "customers.csv", + filename: "/Users/test/workspace/customers.csv", + }], + }]; + const targets: OpenTarget[] = [{ + id: "file:customers.csv", + kind: "file", + value: "customers.csv", + name: "customers.csv", + preview: "sheet", + confidence: 95, + reason: "attachment source", + exists: true, + }]; + + expect(getArtifactsFromMessages(messages, targets)[0]?.legacy_target).toMatchObject({ + value: "customers.csv", + exists: true, + }); + }); +}); diff --git a/apps/app/scripts/open-target.test.ts b/apps/app/scripts/open-target.test.ts index 6563c6d779..c113482483 100644 --- a/apps/app/scripts/open-target.test.ts +++ b/apps/app/scripts/open-target.test.ts @@ -75,6 +75,41 @@ describe("deriveOpenTargets", () => { expect(targets[0]).toMatchObject({ value: "reports/summary.md", preview: "markdown", confidence: 95 }); }); + it("extracts artifact targets from attachment sources", () => { + const targets = deriveOpenTargets([ + { + id: "msg_attachment", + role: "assistant", + parts: [{ + type: "source-document", + sourceId: "attachment-source", + mediaType: "text/csv", + title: "customers.csv", + filename: "reports/customers.csv", + }], + }, + ]); + + expect(targets[0]).toMatchObject({ value: "reports/customers.csv", preview: "sheet", confidence: 95 }); + }); + + it("keeps URI-backed source documents as URL targets when filename is missing", () => { + const targets = deriveOpenTargets([ + { + id: "msg_source", + role: "assistant", + parts: [{ + type: "source-document", + sourceId: "url-source", + mediaType: "text/html", + title: "https://example.com/docs/report.html", + }], + }, + ]); + + expect(targets[0]).toMatchObject({ kind: "url", value: "https://example.com/docs/report.html", preview: "browser" }); + }); + it("does not extract file artifacts from read tool metadata or output", () => { const targets = deriveOpenTargets([ toolMessage( diff --git a/apps/app/src/app/index.css b/apps/app/src/app/index.css index a2a40a2557..7142abfe90 100644 --- a/apps/app/src/app/index.css +++ b/apps/app/src/app/index.css @@ -409,7 +409,7 @@ select:disabled { --card-foreground: var(--slate-12); --popover: var(--slate-1); --popover-foreground: var(--slate-12); - --primary: var(--blue-8); + --primary: var(--blue-10); --primary-foreground: var(--slate-12); --secondary: var(--slate-3); --secondary-foreground: var(--slate-12); diff --git a/apps/app/src/components/ai-elements/chain-of-thought.tsx b/apps/app/src/components/ai-elements/chain-of-thought.tsx new file mode 100644 index 0000000000..eca8a9c4b7 --- /dev/null +++ b/apps/app/src/components/ai-elements/chain-of-thought.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { useControllableState } from "@radix-ui/react-use-controllable-state"; +import { Badge } from "@/components/ui/badge"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import type { LucideIcon } from "lucide-react"; +import { BrainIcon, ChevronDownIcon, DotIcon } from "lucide-react"; +import type { ComponentProps, ReactNode } from "react"; +import { createContext, memo, useContext, useMemo } from "react"; + +interface ChainOfThoughtContextValue { + isOpen: boolean; + setIsOpen: (open: boolean) => void; +} + +const ChainOfThoughtContext = createContext( + null +); + +const useChainOfThought = () => { + const context = useContext(ChainOfThoughtContext); + if (!context) { + throw new Error( + "ChainOfThought components must be used within ChainOfThought" + ); + } + return context; +}; + +export type ChainOfThoughtProps = ComponentProps<"div"> & { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +}; + +export const ChainOfThought = memo( + ({ + className, + open, + defaultOpen = false, + onOpenChange, + children, + ...props + }: ChainOfThoughtProps) => { + const [isOpen, setIsOpen] = useControllableState({ + defaultProp: defaultOpen, + onChange: onOpenChange, + prop: open, + }); + + const chainOfThoughtContext = useMemo( + () => ({ isOpen, setIsOpen }), + [isOpen, setIsOpen] + ); + + return ( + +
+ {children} +
+
+ ); + } +); + +export type ChainOfThoughtHeaderProps = ComponentProps< + typeof CollapsibleTrigger +>; + +export const ChainOfThoughtHeader = memo( + ({ className, children, ...props }: ChainOfThoughtHeaderProps) => { + const { isOpen, setIsOpen } = useChainOfThought(); + + return ( + + + + + {children ?? "Chain of Thought"} + + + + + ); + } +); + +export type ChainOfThoughtStepProps = ComponentProps<"div"> & { + icon?: LucideIcon; + label: ReactNode; + description?: ReactNode; + status?: "complete" | "active" | "pending"; +}; + +const stepStatusStyles = { + active: "text-foreground", + complete: "text-muted-foreground", + pending: "text-muted-foreground/50", +}; + +export const ChainOfThoughtStep = memo( + ({ + className, + icon: Icon = DotIcon, + label, + description, + status = "complete", + children, + ...props + }: ChainOfThoughtStepProps) => ( +
+
+ +
+
+
+
{label}
+ {description && ( +
{description}
+ )} + {children} +
+
+ ) +); + +export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">; + +export const ChainOfThoughtSearchResults = memo( + ({ className, ...props }: ChainOfThoughtSearchResultsProps) => ( +
+ ) +); + +export type ChainOfThoughtSearchResultProps = ComponentProps; + +export const ChainOfThoughtSearchResult = memo( + ({ className, children, ...props }: ChainOfThoughtSearchResultProps) => ( + + {children} + + ) +); + +export type ChainOfThoughtContentProps = ComponentProps< + typeof CollapsibleContent +>; + +export const ChainOfThoughtContent = memo( + ({ className, children, ...props }: ChainOfThoughtContentProps) => { + const { isOpen } = useChainOfThought(); + + return ( + + + {children} + + + ); + } +); + +export type ChainOfThoughtImageProps = ComponentProps<"div"> & { + caption?: string; +}; + +export const ChainOfThoughtImage = memo( + ({ className, children, caption, ...props }: ChainOfThoughtImageProps) => ( +
+
+ {children} +
+ {caption &&

{caption}

} +
+ ) +); + +ChainOfThought.displayName = "ChainOfThought"; +ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader"; +ChainOfThoughtStep.displayName = "ChainOfThoughtStep"; +ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults"; +ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult"; +ChainOfThoughtContent.displayName = "ChainOfThoughtContent"; +ChainOfThoughtImage.displayName = "ChainOfThoughtImage"; diff --git a/apps/app/src/components/chat/artifact-icon.tsx b/apps/app/src/components/chat/artifact-icon.tsx new file mode 100644 index 0000000000..284878c0bc --- /dev/null +++ b/apps/app/src/components/chat/artifact-icon.tsx @@ -0,0 +1,54 @@ +/** @jsxImportSource react */ +import { File, FileAudio, FileCode, FileImage, FileSpreadsheet, FileText, FileType, FileVideo, Globe, Presentation } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import type { ArtifactType } from "@/lib/artifacts"; + +interface ArtifactIconProps { + className?: string; + type: ArtifactType; +} + +export function ArtifactIcon({ className, type }: ArtifactIconProps) { + if (type === "website") { + return ; + } + + if (type === "markdown") { + return ; + } + + if (type === "sheet") { + return ; + } + + if (type === "slides") { + return ; + } + + if (type === "image") { + return ; + } + + if (type === "video") { + return ; + } + + if (type === "audio") { + return ; + } + + if (type === "pdf") { + return ; + } + + if (type === "html") { + return ; + } + + if (type === "text") { + return ; + } + + return ; +} diff --git a/apps/app/src/components/chat/artifact.tsx b/apps/app/src/components/chat/artifact.tsx new file mode 100644 index 0000000000..4cd2b2380e --- /dev/null +++ b/apps/app/src/components/chat/artifact.tsx @@ -0,0 +1,67 @@ +/** @jsxImportSource react */ + +import type { UIMessage } from "ai"; +import { ArrowUpRightIcon } from "lucide-react"; + +import { ArtifactIcon } from "@/components/chat/artifact-icon"; +import { + DescriptiveButton, + DescriptiveButtonContent, + DescriptiveButtonDescription, + DescriptiveButtonIcon, + DescriptiveButtonTitle, +} from "@/components/descriptive-button"; +import { + type ArtifactItem, + getArtifactTypeLabel, + useArtifacts, + usePreviewArtifact, +} from "@/lib/artifacts"; + +interface ArtifactButtonProps { + artifact: ArtifactItem +} + +function ArtifactButton({ artifact }: ArtifactButtonProps) { + const previewArtifact = usePreviewArtifact(); + + return ( + previewArtifact(artifact)} + > + + + + + {artifact.name} + + {getArtifactTypeLabel(artifact.type)} + + + + + ); +} + +interface ArtifactListProps { + messages: UIMessage[] +} + +export function ArtifactList({ messages }: ArtifactListProps) { + const artifacts = useArtifacts(messages); + + if (artifacts.length === 0) { + return null; + } + + return ( +
+
+ {artifacts.map((artifact) => ( + + ))} +
+
+ ); +} diff --git a/apps/app/src/components/chat/message-list-provider.tsx b/apps/app/src/components/chat/message-list-provider.tsx new file mode 100644 index 0000000000..13b91371c9 --- /dev/null +++ b/apps/app/src/components/chat/message-list-provider.tsx @@ -0,0 +1,97 @@ +"use memo"; + +import { useSessionActivityStore } from "@/react-app/domains/session/status/session-activity-store" +import * as React from "react" + +interface MessageListContextValue { + workspaceId: string + sessionId: string + showThinking: boolean + developerMode: boolean + displaySuggestions: boolean + dispatchAction: (action: DispatchAction) => void + setPrompt: (prompt: string) => void + onRevertToUserMessage: (messageId: string) => void + onForkAtMessage: (messageId: string) => void +} + +const MessageListContext = React.createContext(null) + +interface MessageListProviderProps { + children: React.ReactNode + workspaceId: string + sessionId: string + showThinking: boolean + developerMode: boolean + onRevertToUserMessage: (messageId: string) => void + onForkAtMessage: (messageId: string) => void + displaySuggestions: boolean + dispatchAction: (action: DispatchAction) => void + setPrompt: (prompt: string) => void +} + +export interface DispatchAction { + target: "settings" + action: "open" + section: "commands" | "skills" | "mcps" | "plugins" +} + +export function MessageListProvider({ + children, + workspaceId, + sessionId, + showThinking, + developerMode, + displaySuggestions, + dispatchAction, + setPrompt, + onRevertToUserMessage, + onForkAtMessage, +}: MessageListProviderProps) { + const value = React.useMemo( + () => ({ + workspaceId, + sessionId, + showThinking, + developerMode, + displaySuggestions, + dispatchAction, + setPrompt, + onRevertToUserMessage, + onForkAtMessage, + }), + [ + workspaceId, + sessionId, + showThinking, + developerMode, + displaySuggestions, + dispatchAction, + setPrompt, + onRevertToUserMessage, + onForkAtMessage, + ], + ) + + return ( + + {children} + + ) +} + +export function useMessageList() { + const context = React.useContext(MessageListContext) + + if (!context) { + throw new Error("useMessageList must be used within a MessageListProvider") + } + + return context +} + +export function useSessionErrorMessage() { + const { workspaceId, sessionId } = useMessageList(); + + return useSessionActivityStore(state => state.getSessionError(workspaceId, sessionId)); +} \ No newline at end of file diff --git a/apps/app/src/components/chat/message-list.tsx b/apps/app/src/components/chat/message-list.tsx new file mode 100644 index 0000000000..d96101f0ff --- /dev/null +++ b/apps/app/src/components/chat/message-list.tsx @@ -0,0 +1,628 @@ +"use memo"; + +import * as React from "react" +import { + AlertTriangle, + Check, + Copy, + FileIcon, + Split, + Undo2, +} from "lucide-react" +import { + AnimatePresence, + LayoutGroup, + motion, +} from "motion/react" +import { PaperGrainGradient } from "@openwork/ui/react" +import { + DynamicToolUIPart, + isFileUIPart, + ToolUIPart, + type FileUIPart, + type UIMessage, +} from "ai" +import { ApplyPatchTool } from "@/components/tools/apply-patch" +import { BashTool } from "@/components/tools/bash" +import { EditTool } from "@/components/tools/edit" +import { ReadFileTool, WriteFileTool } from "@/components/tools/file" +import { GlobTool } from "@/components/tools/glob" +import { GrepTool } from "@/components/tools/grep" +import { LspTool } from "@/components/tools/lsp" +import { QuestionTool } from "@/components/tools/question" +import { SkillTool } from "@/components/tools/skill" +import { TodoWriteTool } from "@/components/tools/todowrite" +import { WebfetchTool } from "@/components/tools/webfetch" +import { WebsearchTool } from "@/components/tools/websearch" +import { useMessageList, useSessionErrorMessage } from "@/components/chat/message-list-provider" +import { ArtifactList } from "@/components/chat/artifact" +import { TaskSuggestions } from "@/components/chat/task-suggestions" +import { + DescriptiveButton, + DescriptiveButtonContent, + DescriptiveButtonDescription, + DescriptiveButtonIcon, + DescriptiveButtonTitle, +} from "@/components/descriptive-button" +import { Button } from "@/components/ui/button" +import { Image } from "@/components/ui/image" +import { + Message, + MessageAction, + MessageActions, + MessageContent, +} from "@/components/ui/message" +import { + Steps, + StepsContent, + StepsTrigger, +} from "@/components/ui/steps" +import { Tool } from "@/components/ui/tool" +import { + isApplyPatchToolPart, + isBashToolPart, + isEditToolPart, + isGlobToolPart, + isGrepToolPart, + isLspToolPart, + isQuestionToolPart, + isReadToolPart, + isSkillToolPart, + isTodoWriteToolPart, + isWebFetchToolPart, + isWebSearchToolPart, + isWriteToolPart, +} from "@/lib/build-in-tools" +import type { ThreadStatus } from "@/lib/messages" +import { cn } from "@/lib/utils" +import { groupMessages, isMessageGroup, getLastTextPart, getAssistantRenderGroups, getFileTitle, getMediaBadge, type UIMessageWithIndex, getMessagesText } from "./utils" + +interface ToolMessageProps { + part: ToolUIPart | DynamicToolUIPart +} + +const ToolMessage = ({ part }: ToolMessageProps) => { + if (isBashToolPart(part)) { + return + } + + if (isEditToolPart(part)) { + return + } + + if (isWriteToolPart(part)) { + return + } + + if (isReadToolPart(part)) { + return + } + + if (isGrepToolPart(part)) { + return + } + + if (isGlobToolPart(part)) { + return + } + + if (isLspToolPart(part)) { + return + } + + if (isApplyPatchToolPart(part)) { + return + } + + if (isSkillToolPart(part)) { + return + } + + if (isTodoWriteToolPart(part)) { + return + } + + if (isWebFetchToolPart(part)) { + return + } + + if (isWebSearchToolPart(part)) { + return + } + + if (isQuestionToolPart(part)) { + return + } + + return +} + +const isEmptyMessage = (message: UIMessage): boolean => message.parts.length === 0 + +interface FileMessageProps { + part: FileUIPart + tone: "assistant" | "user" +} + +function FileMessage({ part, tone }: FileMessageProps) { + const title = getFileTitle(part) + const badge = getMediaBadge(part) + const isImage = part.mediaType.startsWith("image/") && part.url + + if (isImage) { + return ( + {title} + ) + } + + return ( + + + + + + {title} + {badge ? ( + + {badge} + + ) : null} + + + ) +} + +function EmptyMessage({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ Empty message +
+ ) +} + +interface CopyMessageButtonProps { + messages: UIMessage[] +} + +function CopyMessageButton({ messages }: CopyMessageButtonProps) { + const [copied, setCopied] = React.useState(false) + const text = React.useMemo(() => getMessagesText(messages), [messages]) + + const onCopy = React.useCallback(async () => { + if (!text) { + return + } + + try { + await navigator.clipboard.writeText(text) + setCopied(true) + window.setTimeout(() => setCopied(false), 2000) + } catch { + // ignore clipboard failures + } + }, [text]) + + if (!text) { + return null + } + + return ( + + + + ) +} + +type AssistantMessageProps = { + message: UIMessage + isLastMessage: boolean + isStreaming: boolean + isLastStep: boolean +} + +export const AssistantMessage = React.memo( + ({ message }: AssistantMessageProps) => { + const { showThinking } = useMessageList() + const assistantRenderGroups = React.useMemo( + () => getAssistantRenderGroups(message.parts, showThinking), + [message.parts, showThinking] + ) + + return ( + +
+ {assistantRenderGroups.map((group, index) => { + if (group.kind === "text") { + return ( + + {group.text} + + ) + } + + if (group.kind === "reasoning") { + return ( + + {group.text} + + ) + } + + if (group.kind === "file") { + return ( +
+ +
+ ) + } + + return ( +
+ +
+ ) + })} +
+
+ ) + } +) + +AssistantMessage.displayName = "AssistantMessage" + +type UserMessageProps = { + message: UIMessage + isStreaming: boolean +} + +export const UserMessage = React.memo( + ({ message, isStreaming }: UserMessageProps) => { + const { onRevertToUserMessage, onForkAtMessage } = useMessageList() + + return ( + +
+ {message.parts.filter(isFileUIPart).map((part, index) => ( + + ))} + {message.parts.some((part) => part.type === "text" && part.text) ? ( + + {message.parts.map((part) => (part.type === "text" ? part.text : "")).join("")} + + ) : null} + {!isStreaming && ( + + + + + + + + + + )} +
+
+ ) + } +) + +UserMessage.displayName = "UserMessage" + +type MessageComponentProps = { + message: UIMessage + isLastMessage: boolean + isStreaming: boolean + isLastStep: boolean +} + +export const MessageComponent = React.memo( + ({ message, isLastMessage, isStreaming, isLastStep }: MessageComponentProps) => { + if (isEmptyMessage(message) && !isStreaming) { + return ( + + ) + } + + if (message.role === "assistant") { + return ( + + ) + } + + return ( + + ) + } +) + +MessageComponent.displayName = "MessageComponent" + +const LoadingMessage = React.memo(() => ( + +
+
+
+ +
+ Thinking… +
+
+
+)) + +LoadingMessage.displayName = "LoadingMessage" + +interface ErrorMessageProps { + error: string | null +} + +const ErrorMessage = React.memo(({ error }: ErrorMessageProps) => ( + +
+
+ +

{error}

+
+
+
+)) + +ErrorMessage.displayName = "ErrorMessage" + +const isMessageEmptyGroup = (messages: UIMessageWithIndex[]) => + messages.every(message => isEmptyMessage(message.message)); + +const getRenderableMessages = (messages: UIMessageWithIndex[]) => + messages.flatMap((item) => { + const parts = item.message.parts.filter((part) => part.type === "text" || part.type === "file"); + + return parts.length > 0 ? [{ ...item, message: { ...item.message, parts } }] : [] + }) + +interface AssistantMessageGroupProps { + items: UIMessageWithIndex[] + messages: UIMessage[] + isStreaming: boolean +} + +function MessageGroup({ + items, + messages, + isStreaming, +}: AssistantMessageGroupProps) { + const { onRevertToUserMessage, onForkAtMessage } = useMessageList() + const [open, setOpen] = React.useState(false) + // Only run layout animations while the collapsible is expanding/collapsing. + // Otherwise (e.g. while streaming) layout changes apply instantly. + const [isAnimating, setIsAnimating] = React.useState(false) + const layoutTransition = isAnimating + ? { type: "spring" as const, bounce: 0.1, duration: 0.1 } + : { duration: 0 } + + const lastItem = items[items.length - 1] + + if (!lastItem || isMessageEmptyGroup(items)) { + if (isStreaming) { + return null; + } + + return + } + + const renderableItems = getRenderableMessages(items) + const lastTextMessage = getLastTextPart(lastItem.message) + + return ( + +
+ { + setIsAnimating(true) + setOpen(next) + }} + > + + {items.length} steps + + + {items.map((item, groupIndex) => { + const isLastMessage = item.index === messages.length - 1 + const isLastStep = groupIndex === items.length - 1 + + return ( + setIsAnimating(false)} + > + + + ) + })} + + + + {!open ? renderableItems.map(({ index, message }) => ( + setIsAnimating(false)} + > + + + )) : null} + + item.message)} /> + {lastTextMessage && !isStreaming && ( +
+ + item.message)} /> + + + + + + + + {/* item.message)} /> */} +
+ )} + {renderableItems.length === 0 && !isStreaming ? : null} +
+
+ ) +} + +interface MessageListProps { + messages: UIMessage[] + status: ThreadStatus +} + +export function MessageList({ messages, status }: MessageListProps) { + const isStreaming = status === "streaming" || status === "retrying" + const items = React.useMemo(() => groupMessages(messages, status), [messages, status]); + const error = useSessionErrorMessage(); + + return ( +
+ {messages.length === 0 && } + + {items.map((item) => { + if (isMessageGroup(item)) { + return ( + + ) + } + + const isLastMessage = item.index === messages.length - 1 + const isLastStep = + !messages[item.index + 1] || messages[item.index + 1].role !== item.message.role + + return ( +
+ +
+ ) + })} + + {status === "streaming" && } + {error ? : null} +
+ ) +} diff --git a/apps/app/src/components/chat/message-sources.tsx b/apps/app/src/components/chat/message-sources.tsx new file mode 100644 index 0000000000..3ec863b0f8 --- /dev/null +++ b/apps/app/src/components/chat/message-sources.tsx @@ -0,0 +1,121 @@ +"use memo"; + +import * as React from "react" +import type { UIMessage } from "ai" +import { + Source, + SourceContent, + SourceTrigger, +} from "@/components/ui/source" +import { isWebFetchToolPart, isWebSearchToolPart } from "@/lib/build-in-tools" +import { parseWebSearchResults } from "@/lib/websearch-results" + +type MessageSourceItem = { + key: string + href: string + title: string + description?: string + showFavicon: boolean +} + +function getMessageSourceItems(messages: UIMessage[]): MessageSourceItem[] { + const seen = new Set() + const items: MessageSourceItem[] = [] + + for (const message of messages) { + for (const part of message.parts) { + if (part.type === "source-url") { + if (seen.has(part.url)) { + continue + } + + seen.add(part.url) + items.push({ + key: part.sourceId, + href: part.url, + title: part.title ?? part.url, + showFavicon: true, + }) + continue + } + + if (part.type === "source-document") { + if (seen.has(part.sourceId)) { + continue + } + + seen.add(part.sourceId) + items.push({ + key: part.sourceId, + href: part.filename ?? part.title, + title: part.title, + showFavicon: false, + }) + continue + } + + if (part.type === "dynamic-tool" && isWebFetchToolPart(part) && part.state === "output-available") { + const url = part.input.url + if (seen.has(url)) { + continue + } + + seen.add(url) + items.push({ + key: part.toolCallId, + href: url, + title: url, + showFavicon: true, + }) + continue + } + + if (part.type === "dynamic-tool" && isWebSearchToolPart(part) && part.state === "output-available") { + for (const result of parseWebSearchResults(part.output)) { + if (seen.has(result.url)) { + continue + } + + seen.add(result.url) + items.push({ + key: `${part.toolCallId}-${result.url}`, + href: result.url, + title: result.title, + description: result.description, + showFavicon: true, + }) + } + } + } + } + + return items +} + +interface MessageSourcesProps { + messages: UIMessage[] +} + +export const MessageSources = React.memo(({ messages }: MessageSourcesProps) => { + const sources = React.useMemo(() => getMessageSourceItems(messages), [messages]) + + if (sources.length === 0) { + return null + } + + return ( +
+ {sources.map((source) => ( + + + + + ))} +
+ ) +}) + +MessageSources.displayName = "MessageSources" diff --git a/apps/app/src/components/chat/task-suggestions.tsx b/apps/app/src/components/chat/task-suggestions.tsx new file mode 100644 index 0000000000..c5bd4a3d51 --- /dev/null +++ b/apps/app/src/components/chat/task-suggestions.tsx @@ -0,0 +1,76 @@ +"use client" + +import { + DescriptiveButton, + DescriptiveButtonContent, + DescriptiveButtonDescription, + DescriptiveButtonIcon, + DescriptiveButtonTitle, +} from "@/components/descriptive-button" +import { useMessageList } from "@/components/chat/message-list-provider" +import { cn } from "@/lib/utils" +import { CubeIcon, DocumentChartBarIcon, GlobeAltIcon } from "@heroicons/react/24/solid" + +const CSV_PROMPT = + "Create a sample CSV file with 20 rows of fake customer data (name, email, company, revenue). Then show me a summary of the data." + +const BROWSER_PROMPT = + "Open craigslist.org in the browser and search for couches for sale. Show me the top 5 results with prices." + +interface TaskSuggestionsProps { + className?: string +} + +export function TaskSuggestions({ className }: TaskSuggestionsProps) { + const { displaySuggestions, dispatchAction, setPrompt } = useMessageList() + + if (!displaySuggestions) { + return null + } + + return ( +
+

Try one of these:

+
+ setPrompt(CSV_PROMPT)}> + + + + + Edit a CSV + Create a sample spreadsheet + + + + setPrompt(BROWSER_PROMPT)}> + + + + + Browse the web + Search Craigslist for couches + + + + + dispatchAction({ + target: "settings", + action: "open", + section: "mcps", + }) + } + > + + + + + Connect an extension + Add MCPs and integrations + + +
+
+ ) +} diff --git a/apps/app/src/components/chat/utils.ts b/apps/app/src/components/chat/utils.ts new file mode 100644 index 0000000000..bad603bfe8 --- /dev/null +++ b/apps/app/src/components/chat/utils.ts @@ -0,0 +1,156 @@ +import { isReasoningUIPart, isToolUIPart, type DynamicToolUIPart, type FileUIPart, type ToolUIPart, type UIMessage } from "ai" +import type { ThreadStatus } from "@/lib/messages" + +interface MessageGroup { + messages: UIMessageWithIndex[] +} + +export type UIMessageWithIndex = { index: number, message: UIMessage } +export type MessageListItem = MessageGroup | UIMessageWithIndex + +export function getMessageText(message: UIMessage): string { + return message.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("") + .trim() +} + +export function getMessagesText(messages: UIMessage[]): string { + return messages + .map(getMessageText) + .filter(Boolean) + .join("\n\n") +} + +export function getLastTextPart(message: UIMessage): UIMessage | null { + const lastTextPart = message.parts.findLast((part) => part.type === "text") + + return lastTextPart ? { ...message, parts: [lastTextPart] } : null +} + +export function getFileTitle(part: FileUIPart) { + if (part.filename) { + return part.filename + } + + if (part.url.startsWith("data:")) { + return "Attached file" + } + + return part.url || "File" +} + +export function getMediaBadge(part: FileUIPart) { + if (part.mediaType && part.mediaType !== "application/octet-stream") { + return part.mediaType.replace(/^application\//, "").replace(/^text\//, "").toUpperCase() + } + + return part.filename?.split(".").pop()?.toUpperCase() ?? null +} + +export function isMessageGroup(item: MessageListItem): item is MessageGroup { + return "messages" in item +} + +export function isFollowedByUserMessage(messages: UIMessage[], index: number) { + return index < messages.length && messages[index].role === "user" +} + +export function groupMessages(messages: UIMessage[], status: ThreadStatus): MessageListItem[] { + const items: MessageListItem[] = [] + let index = 0 + + while (index < messages.length) { + const message = messages[index] + + if (message.role !== "assistant") { + items.push({ index, message }) + index++ + continue + } + + const assistantMessages: UIMessageWithIndex[] = [] + + while (index < messages.length && messages[index].role === "assistant") { + assistantMessages.push({ message: messages[index], index }); + index++ + } + + items.push({ messages: assistantMessages }); + } + + return items +} + +export type AssistantRenderGroup = + | { kind: "text"; text: string } + | { kind: "reasoning"; text: string; isStreaming: boolean } + | { kind: "file"; part: FileUIPart } + | { kind: "tool"; part: ToolUIPart | DynamicToolUIPart } + +export function getAssistantRenderGroups( + parts: UIMessage["parts"], + showThinking: boolean +): AssistantRenderGroup[] { + const filteredParts = parts.filter((part) => showThinking || !isReasoningUIPart(part)) + const groups: AssistantRenderGroup[] = [] + + const appendText = (text: string) => { + if (!text) { + return + } + + const previous = groups.at(-1) + if (previous?.kind === "text") { + previous.text += text + return + } + + groups.push({ kind: "text", text }) + } + + const appendReasoning = (part: UIMessage["parts"][number]) => { + if (!isReasoningUIPart(part)) { + return + } + + const previous = groups.at(-1) + if (previous?.kind === "reasoning") { + previous.text += part.text + previous.isStreaming = previous.isStreaming || part.state === "streaming" + return + } + + if (!part.text.trim()) { + return + } + + groups.push({ kind: "reasoning", text: part.text, isStreaming: part.state === "streaming" }) + } + + for (const part of filteredParts) { + if (part.type === "text") { + appendText(part.text) + continue + } + + if (isReasoningUIPart(part)) { + if (showThinking) { + appendReasoning(part) + } + continue + } + + if (part.type === "file") { + groups.push({ kind: "file", part }) + continue + } + + if (isToolUIPart(part)) { + groups.push({ kind: "tool", part }) + } + } + + return groups +} diff --git a/apps/app/src/components/descriptive-button.tsx b/apps/app/src/components/descriptive-button.tsx new file mode 100644 index 0000000000..d78e9b4c84 --- /dev/null +++ b/apps/app/src/components/descriptive-button.tsx @@ -0,0 +1,58 @@ +import type * as React from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +type DescriptiveButtonProps = React.ComponentProps & { + orientation?: "horizontal" | "vertical"; +} + +export function DescriptiveButton({ children, className, orientation = "horizontal", ...props }: DescriptiveButtonProps) { + return ( + + ) +} + +type DescriptiveButtonIconProps = React.ComponentProps<"div"> + +export function DescriptiveButtonIcon({ children, className, ...props }: DescriptiveButtonIconProps) { + return ( +
+ {children} +
+ ) +} + +type DescriptiveButtonTitleProps = React.ComponentProps<"span"> + +export function DescriptiveButtonTitle({ children, className, ...props }: DescriptiveButtonTitleProps) { + return ( + {children} + ) +} + +type DescriptiveButtonDescriptionProps = React.ComponentProps<"span"> + +export function DescriptiveButtonDescription({ children, className, ...props }: DescriptiveButtonDescriptionProps) { + return ( + {children} + ) +} + +type DescriptiveButtonContentProps = React.ComponentProps<"div"> + +export function DescriptiveButtonContent({ children, className, ...props }: DescriptiveButtonContentProps) { + return ( +
{children}
+ ) +} \ No newline at end of file diff --git a/apps/app/src/components/markdown/markdown.tsx b/apps/app/src/components/markdown/markdown.tsx new file mode 100644 index 0000000000..4837bd17b4 --- /dev/null +++ b/apps/app/src/components/markdown/markdown.tsx @@ -0,0 +1,335 @@ +/** @jsxImportSource react */ +import { memo, useEffect, useMemo, useRef, useState } from "react"; +import { motion } from "motion/react"; +import DOMPurify from "dompurify"; +import { Marked, type Tokens } from "marked"; +import { markedEmoji } from "marked-emoji"; +import markedShiki from "marked-shiki"; +import emojiKeywords from "emojilib"; +import { + transformerMetaHighlight, + transformerMetaWordHighlight, + transformerNotationDiff, + transformerNotationErrorLevel, + transformerNotationFocus, + transformerNotationHighlight, + transformerNotationWordHighlight, +} from "@shikijs/transformers"; +import { bundledLanguages, codeToHtml } from "shiki"; + +import { cn } from "@/lib/utils"; + +import { applyTextHighlights } from "./text-highlights"; + +function escapeHtml(value: string) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function escapeAttribute(value: string) { + return escapeHtml(value).replace(/`/g, "`"); +} + +function safeHref(href: string) { + const trimmed = href.trim(); + + if (!trimmed) { + return "#"; + } + + if (trimmed.startsWith("#") || trimmed.startsWith("/") || trimmed.startsWith("./") || trimmed.startsWith("../")) { + return trimmed; + } + + try { + const parsed = new URL(trimmed); + + if (["http:", "https:", "mailto:"].includes(parsed.protocol)) { + return trimmed; + } + } catch { + return "#"; + } + + return "#"; +} + +function alignAttribute(align: Tokens.TableCell["align"]) { + return align ? ` style="text-align: ${align}"` : ""; +} + +function codeLanguageClass(lang: string | undefined) { + const normalized = lang?.trim().split(/\s+/)[0]; + + return normalized ? ` class="language-${escapeAttribute(normalized)}"` : ""; +} + +function createEmojiAliases() { + const aliases: Record = {}; + + for (const [emoji, names] of Object.entries(emojiKeywords)) { + for (const name of names) { + if (!aliases[name]) { + aliases[name] = emoji; + } + } + } + + return aliases; +} + +const emojiAliases = createEmojiAliases(); + +function parseShikiLanguage(lang: string) { + const normalized = lang.trim().split(/\s+/)[0]?.toLowerCase() ?? ""; + return normalized in bundledLanguages ? normalized : "text"; +} + +function hasFencedCodeBlock(text: string) { + return /(^|\n)```/.test(text); +} + +function sanitizeMarkdownHtml(value: string) { + return DOMPurify.sanitize(value, { + ADD_ATTR: [ + "checked", + "class", + "data-openwork-shiki", + "decoding", + "disabled", + "loading", + "rel", + "start", + "style", + "target", + ], + }); +} + +const baseMarkedOptions = { + async: false, + breaks: false, + gfm: true, + pedantic: false, + silent: true, + renderer: { + html({ text }) { + return text; + }, + paragraph({ tokens }) { + return `

${this.parser.parseInline(tokens)}

`; + }, + heading({ tokens, depth }) { + const className = cn( + "font-semibold", + depth === 1 && "my-5 text-xl", + depth === 2 && "my-4 text-lg", + depth >= 3 && "my-3 text-base", + ); + + return `${this.parser.parseInline(tokens)}`; + }, + list(token) { + const tag = token.ordered ? "ol" : "ul"; + const className = cn( + "my-3 pl-6", + token.ordered ? "list-decimal" : "list-disc", + ); + const start = token.ordered && typeof token.start === "number" && token.start !== 1 + ? ` start="${token.start}"` + : ""; + return `<${tag}${start} class="${className}">${token.items.map((item) => this.listitem(item)).join("")}`; + }, + listitem(item) { + const checkbox = item.task + ? ` ` + : ""; + + return `
  • ${checkbox}${this.parser.parse(item.tokens)}
  • `; + }, + blockquote({ tokens }) { + return `
    ${this.parser.parse(tokens)}
    `; + }, + code({ text, lang }) { + return `
    ${escapeHtml(text)}
    `; + }, + codespan({ text }) { + return `${escapeHtml(text)}`; + }, + del({ raw, tokens }) { + if (!raw.startsWith("~~")) { + return escapeHtml(raw); + } + + return `${this.parser.parseInline(tokens)}`; + }, + link({ href, title, tokens }) { + const safe = escapeAttribute(safeHref(href)); + const titleAttr = title ? ` title="${escapeAttribute(title)}"` : ""; + + return `${this.parser.parseInline(tokens)}`; + }, + image({ href, title, text }) { + const safe = escapeAttribute(safeHref(href)); + const titleAttr = title ? ` title="${escapeAttribute(title)}"` : ""; + + return `${escapeAttribute(text)}`; + }, + table(token) { + const header = token.header.map((cell) => this.tablecell({ ...cell, header: true })).join(""); + const body = token.rows.map((row) => this.tablerow({ text: row.map((cell) => this.tablecell(cell)).join("") })).join(""); + + return `${this.tablerow({ text: header })}${body}
    `; + }, + tablerow({ text }) { + return `${text}`; + }, + tablecell({ tokens, header, align }) { + const className = cn( + "border border-border p-2", + header ? "bg-muted text-left" : "align-top", + ); + + if (header) { + return `${this.parser.parseInline(tokens)}`; + } + + return `${this.parser.parseInline(tokens)}`; + }, + hr() { + return `
    `; + }, + }, +} satisfies ConstructorParameters>[0]; + +const markdownParser = new Marked(baseMarkedOptions).use( + markedEmoji({ + emojis: emojiAliases, + renderer: (token) => escapeHtml(token.emoji), + }), +); + +const highlightedMarkdownParser = new Marked({ + ...baseMarkedOptions, + async: true, +}).use( + markedEmoji({ + emojis: emojiAliases, + renderer: (token) => escapeHtml(token.emoji), + }), + markedShiki({ + async highlight(code, lang, props) { + const language = parseShikiLanguage(lang); + + return codeToHtml(code, { + lang: language, + meta: { __raw: props.join(" ") }, + theme: "github-light", + transformers: [ + transformerNotationDiff({ matchAlgorithm: "v3" }), + transformerNotationHighlight({ matchAlgorithm: "v3" }), + transformerNotationWordHighlight({ matchAlgorithm: "v3" }), + transformerNotationFocus({ matchAlgorithm: "v3" }), + transformerNotationErrorLevel({ matchAlgorithm: "v3" }), + transformerMetaHighlight(), + transformerMetaWordHighlight(), + ], + }); + }, + container: `
    %s
    `, + }), +); + +type MarkdownBlockInnerProps = { + className?: string; + text: string; + streaming?: boolean; + highlightQuery?: string; +} & Omit< + React.ComponentProps, + "ref" | "className" | "children" | "dangerouslySetInnerHTML" +>; + +function MarkdownBlockInner({ + className, + text, + streaming, + highlightQuery, + ...props +}: MarkdownBlockInnerProps) { + const rootRef = useRef(null); + const syncHtml = useMemo(() => { + if (!text.trim()) { + return ""; + } + return sanitizeMarkdownHtml(markdownParser.parse(text, { async: false })); + }, [text]); + const [highlightedHtml, setHighlightedHtml] = useState<{ text: string; html: string } | null>(null); + + useEffect(() => { + if (streaming || !hasFencedCodeBlock(text)) { + setHighlightedHtml(null); + return; + } + + let cancelled = false; + void highlightedMarkdownParser.parse(text, { async: true }).then((html) => { + const sanitizedHtml = sanitizeMarkdownHtml(html); + + if (!cancelled && sanitizedHtml.trim()) { + setHighlightedHtml({ text, html: sanitizedHtml }); + } + }).catch(() => { + if (!cancelled) { + setHighlightedHtml(null); + } + }); + return () => { + cancelled = true; + }; + }, [streaming, text]); + + const html = !streaming && highlightedHtml?.text === text ? highlightedHtml.html : syncHtml; + + useEffect(() => { + const root = rootRef.current; + + if (!root) { + return; + } + + queueMicrotask(() => { + if (!rootRef.current || rootRef.current !== root) { + return; + } + + applyTextHighlights(root, highlightQuery ?? ""); + }); + }, [html, highlightQuery]); + + if (!html) { + return null; + } + + return ( + + ); +} + +/** + * Memoize so a message block that has already been rendered — the usual + * case for every assistant bubble above the currently-streaming one — + * doesn't re-parse its markdown on every token. Only re-renders when its + * own text / streaming / highlightQuery props change. + */ +export const MarkdownBlock = memo(MarkdownBlockInner); +MarkdownBlock.displayName = "MarkdownBlock"; diff --git a/apps/app/src/components/markdown/text-highlights.ts b/apps/app/src/components/markdown/text-highlights.ts new file mode 100644 index 0000000000..163123a047 --- /dev/null +++ b/apps/app/src/components/markdown/text-highlights.ts @@ -0,0 +1,99 @@ +const SEARCH_HIGHLIGHT_MARK_ATTR = "data-search-highlight"; +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +export function clearTextHighlights(root: HTMLElement) { + const marks = root.querySelectorAll(`mark[${SEARCH_HIGHLIGHT_MARK_ATTR}="true"]`); + + for (const mark of marks) { + const parent = mark.parentNode; + + if (!parent) { + continue; + } + + parent.replaceChild(document.createTextNode(mark.textContent ?? ""), mark); + } + + root.normalize(); +} + +export function applyTextHighlights(root: HTMLElement, query: string) { + const needle = query.trim().toLowerCase(); + // Fast path: if search is inactive, avoid walking large message DOM trees. + // We only need to clear existing marks if a previous search actually added + // some. + if (!needle) { + if (root.querySelector(`mark[${SEARCH_HIGHLIGHT_MARK_ATTR}="true"]`)) { + clearTextHighlights(root); + } + + return; + } + + clearTextHighlights(root); + + const needlePattern = new RegExp(escapeRegExp(needle), "g"); + + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + const value = node.nodeValue; + + if (!value || !value.trim()) { + return NodeFilter.FILTER_REJECT; + } + + const parent = node.parentElement; + + if (!parent) { + return NodeFilter.FILTER_REJECT; + } + + if (parent.closest("pre, code")) { + return NodeFilter.FILTER_REJECT; + } + + if (parent.tagName === "SCRIPT" || parent.tagName === "STYLE") { + return NodeFilter.FILTER_REJECT; + } + + return value.toLowerCase().includes(needle) + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + }, + }); + + const nodes: Text[] = []; + let current = walker.nextNode(); + while (current) { + nodes.push(current as Text); + current = walker.nextNode(); + } + + for (const node of nodes) { + const text = node.nodeValue ?? ""; + const lower = text.toLowerCase(); + let searchIndex = 0; + const fragment = document.createDocumentFragment(); + + for (const match of lower.matchAll(needlePattern)) { + const matchIndex = match.index; + + if (matchIndex > searchIndex) { + fragment.appendChild(document.createTextNode(text.slice(searchIndex, matchIndex))); + } + + const mark = document.createElement("mark"); + mark.setAttribute(SEARCH_HIGHLIGHT_MARK_ATTR, "true"); + mark.className = "rounded px-0.5 bg-amber-4/70 text-current"; + mark.textContent = text.slice(matchIndex, matchIndex + needle.length); + fragment.appendChild(mark); + searchIndex = matchIndex + needle.length; + } + + if (searchIndex < text.length) { + fragment.appendChild(document.createTextNode(text.slice(searchIndex))); + } + + node.parentNode?.replaceChild(fragment, node); + } +} diff --git a/apps/app/src/components/tools/apply-patch.tsx b/apps/app/src/components/tools/apply-patch.tsx new file mode 100644 index 0000000000..1b86f9ea27 --- /dev/null +++ b/apps/app/src/components/tools/apply-patch.tsx @@ -0,0 +1,29 @@ +"use client" + +import { Tool } from "@/components/ui/tool" +import type { ApplyPatchToolPart } from "@/lib/build-in-tools" + +interface ApplyPatchToolProps { + part: ApplyPatchToolPart +} + +export function getApplyPatchToolTitle(part: ApplyPatchToolPart): string | null { + if (part.state === "output-error") { + return "Apply patch attempted" + } + + if (part.state !== "output-available") { + return null + } + + return "Apply patch" +} + +export function ApplyPatchTool({ part }: ApplyPatchToolProps) { + return ( + + ) +} diff --git a/apps/app/src/components/tools/bash.tsx b/apps/app/src/components/tools/bash.tsx new file mode 100644 index 0000000000..1f521a1738 --- /dev/null +++ b/apps/app/src/components/tools/bash.tsx @@ -0,0 +1,39 @@ +"use client" + +import { SquareTerminalIcon } from "lucide-react" +import { + CollapsibleTool, + CollapsibleToolContent, + CollapsibleToolStep, + CollapsibleToolTrigger, +} from "@/components/tools/collapsible-tool" +import type { BashToolPart } from "@/lib/build-in-tools" + +interface BashToolProps { + part: BashToolPart +} + +export function BashTool({ part }: BashToolProps) { + return ( + + + }> + + + {part.input.description} + + + {part.input.command} + + + + +
    +
    $ {part.input.command}
    +
    {part.output}
    +
    +
    +
    +
    + ) +} diff --git a/apps/app/src/components/tools/collapsible-tool.tsx b/apps/app/src/components/tools/collapsible-tool.tsx new file mode 100644 index 0000000000..f10280217c --- /dev/null +++ b/apps/app/src/components/tools/collapsible-tool.tsx @@ -0,0 +1,144 @@ +"use client" + +import * as React from "react" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" +import { cn } from "@/lib/utils" +import { ChevronDown, Circle } from "lucide-react" + +export type CollapsibleToolItemProps = React.ComponentProps<"div"> + +export const CollapsibleToolItem = ({ + children, + className, + ...props +}: CollapsibleToolItemProps) => ( +
    + {children} +
    +) + +export type CollapsibleToolTriggerProps = React.ComponentProps< + typeof CollapsibleTrigger +> & { + leftIcon?: React.ReactNode + swapIconOnHover?: boolean +} + +export const CollapsibleToolTrigger = ({ + children, + className, + leftIcon, + swapIconOnHover = true, + ...props +}: CollapsibleToolTriggerProps) => ( + +
    + {leftIcon ? ( + + + {leftIcon} + + {swapIconOnHover && ( + + )} + + ) : ( + + + + )} + {children} +
    + {!leftIcon && ( + + )} +
    +) + +export type CollapsibleToolContentProps = React.ComponentProps< + typeof CollapsibleContent +> + +export const CollapsibleToolContent = ({ + children, + className, + ...props +}: CollapsibleToolContentProps) => { + return ( + + {children} + + ) +} + +export type CollapsibleToolProps = { + children: React.ReactNode + className?: string +} + +export function CollapsibleTool({ children, className }: CollapsibleToolProps) { + const childrenArray = React.Children.toArray(children) + + return ( +
    + {childrenArray.map((child, index) => ( + + {React.isValidElement(child) && + React.cloneElement( + child as React.ReactElement, + { + isLast: index === childrenArray.length - 1, + } + )} + + ))} +
    + ) +} + +export type CollapsibleToolStepProps = { + children: React.ReactNode + className?: string + isLast?: boolean +} + +export const CollapsibleToolStep = ({ + children, + className, + isLast = false, + ...props +}: CollapsibleToolStepProps & React.ComponentProps) => { + return ( + + {children} +
    +
    +
    + + ) +} diff --git a/apps/app/src/components/tools/edit.tsx b/apps/app/src/components/tools/edit.tsx new file mode 100644 index 0000000000..bb1947bf3e --- /dev/null +++ b/apps/app/src/components/tools/edit.tsx @@ -0,0 +1,27 @@ +"use client" + +import { Tool } from "@/components/ui/tool" +import type { EditToolPart } from "@/lib/build-in-tools" +import { parseFilename } from "@/components/tools/path" + +interface EditToolProps { + part: EditToolPart +} + +export function getEditToolTitle(part: EditToolPart): string | null { + const filename = parseFilename(part.input.filePath) + + if (part.state === "output-error") { + return `Update attempted ${filename}` + } + + if (part.state !== "output-available") { + return null + } + + return `Updated ${filename}` +} + +export function EditTool({ part }: EditToolProps) { + return +} diff --git a/apps/app/src/components/tools/file.tsx b/apps/app/src/components/tools/file.tsx new file mode 100644 index 0000000000..31012b9262 --- /dev/null +++ b/apps/app/src/components/tools/file.tsx @@ -0,0 +1,228 @@ +import type { ReadToolPart, WriteToolPart } from "@/lib/build-in-tools" +import { parseFilename } from "@/components/tools/path" + +interface ReadFileToolProps { + part: ReadToolPart +} + +export function ReadFileTool({ part }: ReadFileToolProps) { + const filename = parseFilename(part.input.filePath); + + if (part.state === "output-error") { + return ( +
    + Read attempted {filename} +
    + ) + } + + if (part.state !== "output-available") { + return null; + } + + const output = parseReadOutput(part.output); + + if (output.type === "directory") { + return ( +
    + Listing {filename} +
    + ) + } + + return ( +
    + Read {filename} + {output.lineRange && output.truncated ? ( + <> + {" "} + L{output.lineRange.firstLine}-{output.lineRange.lastLine} + + ) : null} +
    + ) +} + +interface WriteFileToolProps { + part: WriteToolPart +} + +export function WriteFileTool({ part }: WriteFileToolProps) { + const filename = parseFilename(part.input.filePath); + + if (part.state === "output-error") { + return ( +
    + Write attempted {filename} +
    + ) + } + + if (part.state !== "output-available") { + return null; + } + + return ( +
    + Write {filename} +
    + ) +} + +export interface ReadLine { + number: number + text: string +} + +export interface ReadLineRange { + firstLine: number + lastLine: number + totalLines?: number +} + +export interface ParsedReadFileOutput { + type: "file" + lines: ReadLine[] + lineRange?: ReadLineRange + truncated: boolean +} + +export interface ParsedReadDirectoryOutput { + type: "directory" + entries: string[] + truncated: boolean +} + +export type ParsedReadOutput = ParsedReadFileOutput | ParsedReadDirectoryOutput + +const END_OF_FILE_PATTERN = /^\(End of file - total (\d+) lines\)$/ +const OUTPUT_CAPPED_PATTERN = /^\(Output capped at 50 KB\. Showing lines (\d+)-(\d+)\. Use offset=(\d+) to continue\.\)$/ +const PARTIAL_FILE_PATTERN = /^\(Showing lines (\d+)-(\d+) of (\d+)\. Use offset=(\d+) to continue\.\)$/ +const DIRECTORY_COUNT_PATTERN = /^\((\d+) entries\)$/ +const DIRECTORY_TRUNCATED_PATTERN = /^\(Showing (\d+) of (\d+) entries\. Use 'offset' parameter to read beyond entry (\d+)\)$/ +const LINE_PATTERN = /^(\d+): ?(.*)$/ + +export function parseReadOutput(output: string): ParsedReadOutput { + const type = extractTag(output, "type") + + if (type === "directory") { + const entries = extractTag(output, "entries") + const parsedEntries = parseReadEntries(entries) + + return { + type: "directory", + entries: parsedEntries.entries, + truncated: parsedEntries.truncated, + } + } + + const content = extractTag(output, "content") + const parsedContent = parseReadContent(content) + + return { + type: "file", + lines: parsedContent.lines, + lineRange: parsedContent.lineRange, + truncated: parsedContent.truncated, + } +} + +function parseReadContent(content: string | undefined) { + const lines: ReadLine[] = [] + let lineCount: number | undefined + let lineRange: ReadLineRange | undefined + let truncated = false + + for (const rawLine of splitLines(content ?? "")) { + const lineMatch = rawLine.match(LINE_PATTERN) + if (lineMatch) { + lines.push({ + number: Number(lineMatch[1]), + text: lineMatch[2] ?? "", + }) + continue + } + + const endMatch = rawLine.match(END_OF_FILE_PATTERN) + if (endMatch) { + lineCount = Number(endMatch[1]) + continue + } + + const cappedMatch = rawLine.match(OUTPUT_CAPPED_PATTERN) + if (cappedMatch) { + truncated = true + lineRange = { + firstLine: Number(cappedMatch[1]), + lastLine: Number(cappedMatch[2]), + } + continue + } + + const partialMatch = rawLine.match(PARTIAL_FILE_PATTERN) + if (partialMatch) { + truncated = true + lineRange = { + firstLine: Number(partialMatch[1]), + lastLine: Number(partialMatch[2]), + totalLines: Number(partialMatch[3]), + } + } + } + + return { + lines, + lineRange: lineRange ?? getLineRange(lines, lineCount), + truncated, + } +} + +function parseReadEntries(entriesBlock: string | undefined) { + const entries: string[] = [] + let truncated = false + + for (const rawLine of splitLines(entriesBlock ?? "")) { + const line = rawLine.trim() + if (!line) { + continue + } + + const countMatch = line.match(DIRECTORY_COUNT_PATTERN) + if (countMatch) { + continue + } + + const truncatedMatch = line.match(DIRECTORY_TRUNCATED_PATTERN) + if (truncatedMatch) { + truncated = true + continue + } + + entries.push(rawLine) + } + + return { entries, truncated } +} + +function getLineRange(lines: ReadLine[], totalLines: number | undefined): ReadLineRange | undefined { + const first = lines[0] + const last = lines.at(-1) + if (!first || !last) { + return undefined + } + + return { + firstLine: first.number, + lastLine: last.number, + totalLines, + } +} + +function extractTag(output: string, tagName: string): string | undefined { + const pattern = new RegExp(`<${tagName}>([\\s\\S]*?)`) + return pattern.exec(output)?.[1]?.trim() +} + +function splitLines(value: string): string[] { + return value.split(/\r\n|\n|\r/) +} \ No newline at end of file diff --git a/apps/app/src/components/tools/glob.tsx b/apps/app/src/components/tools/glob.tsx new file mode 100644 index 0000000000..ba07b61443 --- /dev/null +++ b/apps/app/src/components/tools/glob.tsx @@ -0,0 +1,47 @@ +"use client" + +import { Tool } from "@/components/ui/tool" +import type { GlobToolPart } from "@/lib/build-in-tools" +import { parseFilename, toolDisplayTitle, truncateText } from "@/components/tools/path" + +interface GlobToolProps { + part: GlobToolPart +} + +export function getGlobToolTitle(part: GlobToolPart): string | null { + const pattern = part.input.pattern.trim() + + if (part.state === "output-error") { + return pattern + ? `Search attempted ${truncateText(pattern, 44)}` + : "Search attempted" + } + + if (part.state !== "output-available") { + return null + } + + return pattern ? `Searched ${truncateText(pattern, 44)}` : "Searched code" +} + +export function getGlobToolDetail(part: GlobToolPart): string | undefined { + const root = part.input.path?.trim() + if (!root) { + return undefined + } + + return `in ${parseFilename(root)}` +} + +export function GlobTool({ part }: GlobToolProps) { + return ( + + ) +} diff --git a/apps/app/src/components/tools/grep.tsx b/apps/app/src/components/tools/grep.tsx new file mode 100644 index 0000000000..4ee0289f5b --- /dev/null +++ b/apps/app/src/components/tools/grep.tsx @@ -0,0 +1,47 @@ +"use client" + +import { Tool } from "@/components/ui/tool" +import type { GrepToolPart } from "@/lib/build-in-tools" +import { parseFilename, toolDisplayTitle, truncateText } from "@/components/tools/path" + +interface GrepToolProps { + part: GrepToolPart +} + +export function getGrepToolTitle(part: GrepToolPart): string | null { + const pattern = part.input.pattern.trim() + + if (part.state === "output-error") { + return pattern + ? `Search attempted ${truncateText(pattern, 44)}` + : "Search attempted" + } + + if (part.state !== "output-available") { + return null + } + + return pattern ? `Searched ${truncateText(pattern, 44)}` : "Searched code" +} + +export function getGrepToolDetail(part: GrepToolPart): string | undefined { + const root = part.input.path?.trim() + if (!root) { + return undefined + } + + return `in ${parseFilename(root)}` +} + +export function GrepTool({ part }: GrepToolProps) { + return ( + + ) +} diff --git a/apps/app/src/components/tools/lsp.tsx b/apps/app/src/components/tools/lsp.tsx new file mode 100644 index 0000000000..0c2cf81f0e --- /dev/null +++ b/apps/app/src/components/tools/lsp.tsx @@ -0,0 +1,62 @@ +"use client" + +import { Tool } from "@/components/ui/tool" +import type { LspInput, LspToolPart } from "@/lib/build-in-tools" +import { parseFilename, toolDisplayTitle } from "@/components/tools/path" + +interface LspToolProps { + part: LspToolPart +} + +const LSP_OPERATION_LABELS: Record = { + goToDefinition: "Go to definition", + findReferences: "Find references", + hover: "Hover", + documentSymbol: "Document symbols", + workspaceSymbol: "Workspace symbols", + goToImplementation: "Go to implementation", + prepareCallHierarchy: "Prepare call hierarchy", + incomingCalls: "Incoming calls", + outgoingCalls: "Outgoing calls", +} + +export function getLspToolTitle(part: LspToolPart): string | null { + const filename = parseFilename(part.input.filePath) + const operation = LSP_OPERATION_LABELS[part.input.operation] + + if (part.state === "output-error") { + return `${operation} attempted in ${filename}` + } + + if (part.state !== "output-available") { + return null + } + + return `${operation} in ${filename}` +} + +export function getLspToolDetail(part: LspToolPart): string | undefined { + const line = part.input.line + const character = part.input.character + const query = part.input.query?.trim() + + const location = `L${line}:${character}` + if (query) { + return `${location} · ${query}` + } + + return location +} + +export function LspTool({ part }: LspToolProps) { + return ( + + ) +} diff --git a/apps/app/src/components/tools/path.ts b/apps/app/src/components/tools/path.ts new file mode 100644 index 0000000000..03fd3f5ea2 --- /dev/null +++ b/apps/app/src/components/tools/path.ts @@ -0,0 +1,22 @@ +export function parseFilename(filePath: string | undefined) { + if (!filePath) { + return "file" + } + return filePath.replace(/\\/g, "/").split("/").pop() ?? filePath +} + +export function truncateText(value: string, max: number) { + return value.length > max ? `${value.slice(0, Math.max(0, max - 3))}...` : value +} + +export function toolDisplayTitle( + title: string | null, + fallback: string, + detail?: string +) { + const primary = title ?? fallback + if (!detail) { + return primary + } + return `${primary} ${detail}` +} diff --git a/apps/app/src/components/tools/question.tsx b/apps/app/src/components/tools/question.tsx new file mode 100644 index 0000000000..494a4b7f0a --- /dev/null +++ b/apps/app/src/components/tools/question.tsx @@ -0,0 +1,67 @@ +"use client" + +import { Tool } from "@/components/ui/tool" +import type { QuestionToolPart } from "@/lib/build-in-tools" +import { toolDisplayTitle, truncateText } from "@/components/tools/path" + +interface QuestionToolProps { + part: QuestionToolPart +} + +function getFirstQuestionLabel(part: QuestionToolPart) { + const first = part.input.questions[0] + if (!first) { + return undefined + } + + const header = first.header.trim() + const question = first.question.trim() + const label = header || question + return label ? truncateText(label, 56) : undefined +} + +export function getQuestionToolTitle(part: QuestionToolPart): string | null { + const label = getFirstQuestionLabel(part) + const count = part.input.questions.length + + if (part.state === "output-error") { + return label ?? "Asked a question" + } + + if (part.state !== "output-available") { + return null + } + + if (label) { + return label + } + + return count > 1 ? `Asked ${count} questions` : "Asked a question" +} + +export function getQuestionToolDetail(part: QuestionToolPart): string | undefined { + const count = part.input.questions.length + + if (part.state === "output-available") { + return "Answered" + } + + if (count > 1) { + return `${count} questions` + } + + return undefined +} + +export function QuestionTool({ part }: QuestionToolProps) { + return ( + + ) +} diff --git a/apps/app/src/components/tools/skill.tsx b/apps/app/src/components/tools/skill.tsx new file mode 100644 index 0000000000..8a13cd7b36 --- /dev/null +++ b/apps/app/src/components/tools/skill.tsx @@ -0,0 +1,26 @@ +"use client" + +import { Tool } from "@/components/ui/tool" +import type { SkillToolPart } from "@/lib/build-in-tools" + +interface SkillToolProps { + part: SkillToolPart +} + +export function getSkillToolTitle(part: SkillToolPart): string | null { + const name = part.input.name.trim() + + if (part.state === "output-error") { + return name ? `Load skill ${name} attempted` : "Load skill attempted" + } + + if (part.state !== "output-available") { + return null + } + + return name ? `Load skill ${name}` : "Load skill" +} + +export function SkillTool({ part }: SkillToolProps) { + return +} diff --git a/apps/app/src/components/tools/todowrite.tsx b/apps/app/src/components/tools/todowrite.tsx new file mode 100644 index 0000000000..9aa3d59a96 --- /dev/null +++ b/apps/app/src/components/tools/todowrite.tsx @@ -0,0 +1,28 @@ +"use client" + +import { Tool } from "@/components/ui/tool" +import type { TodoWriteToolPart } from "@/lib/build-in-tools" + +interface TodoWriteToolProps { + part: TodoWriteToolPart +} + +export function getTodoWriteToolTitle(part: TodoWriteToolPart): string | null { + const count = part.input.todos.length + + if (part.state === "output-error") { + return "Update todo list attempted" + } + + if (part.state !== "output-available") { + return null + } + + return count > 0 ? `Update todo list (${count})` : "Update todo list" +} + +export function TodoWriteTool({ part }: TodoWriteToolProps) { + return ( + + ) +} diff --git a/apps/app/src/components/tools/webfetch.tsx b/apps/app/src/components/tools/webfetch.tsx new file mode 100644 index 0000000000..507171a7a8 --- /dev/null +++ b/apps/app/src/components/tools/webfetch.tsx @@ -0,0 +1,68 @@ +import * as React from "react" +import { Globe } from "lucide-react" +import { + Source, + SourceContent, + SourceTrigger, +} from "@/components/ui/source" +import type { WebFetchToolPart } from "@/lib/build-in-tools" +import { cn } from "@/lib/utils" +import { Tool } from "@/components/ui/tool" + +interface WebfetchToolProps { + part: WebFetchToolPart +} + +export function WebfetchTool({ part }: WebfetchToolProps) { + if (part.state === "output-error") { + return + } + + if (part.state !== "output-available") { + return + } + + return ( +
    + }> + Fetching + + + + + +
    + ) +} + +interface WebfetchTriggerProps { + children: React.ReactNode + className?: string + leftIcon?: React.ReactNode +} + +function WebfetchTrigger({ + children, + className, + leftIcon, +}: WebfetchTriggerProps) { + return ( +
    +
    + {leftIcon && ( + + {leftIcon} + + )} + {children} +
    +
    + ) +} \ No newline at end of file diff --git a/apps/app/src/components/tools/websearch.tsx b/apps/app/src/components/tools/websearch.tsx new file mode 100644 index 0000000000..ad5ecfcc81 --- /dev/null +++ b/apps/app/src/components/tools/websearch.tsx @@ -0,0 +1,56 @@ +"use client" + +import { Search } from "lucide-react" +import { + ChainOfThought, + ChainOfThoughtContent, + ChainOfThoughtStep, + ChainOfThoughtTrigger, +} from "@/components/ui/chain-of-thought" +import { + Source, + SourceContent, + SourceTrigger, +} from "@/components/ui/source" +import type { WebSearchToolPart } from "@/lib/build-in-tools" +import { Tool } from "@/components/ui/tool" +import { parseWebSearchResults } from "@/lib/websearch-results" + +interface WebsearchToolProps { + part: WebSearchToolPart +} + +export function WebsearchTool({ part }: WebsearchToolProps) { + if (part.state === "output-error") { + return + } + + if (part.state !== "output-available") { + return + } + + const results = parseWebSearchResults(part.output) + + return ( + + + }> + {results.length > 0 ? `Searching for "${part.input.query}"` : "Web search (No results)"} + + +
    + {results.map((result) => ( + + + + + ))} +
    +
    +
    +
    + ) +} \ No newline at end of file diff --git a/apps/app/src/components/ui/avatar.tsx b/apps/app/src/components/ui/avatar.tsx new file mode 100644 index 0000000000..d9cfbc5e38 --- /dev/null +++ b/apps/app/src/components/ui/avatar.tsx @@ -0,0 +1,107 @@ +import * as React from "react" +import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}: AvatarPrimitive.Root.Props & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: AvatarPrimitive.Fallback.Props) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
    svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarBadge, +} diff --git a/apps/app/src/components/ui/badge.tsx b/apps/app/src/components/ui/badge.tsx new file mode 100644 index 0000000000..d93b7dcdc4 --- /dev/null +++ b/apps/app/src/components/ui/badge.tsx @@ -0,0 +1,52 @@ +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-3xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + render, + ...props +}: useRender.ComponentProps<"span"> & VariantProps) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + className: cn(badgeVariants({ variant }), className), + }, + props + ), + render, + state: { + slot: "badge", + variant, + }, + }) +} + +export { Badge, badgeVariants } diff --git a/apps/app/src/components/ui/chain-of-thought.tsx b/apps/app/src/components/ui/chain-of-thought.tsx new file mode 100644 index 0000000000..378b1446b6 --- /dev/null +++ b/apps/app/src/components/ui/chain-of-thought.tsx @@ -0,0 +1,148 @@ +"use client" + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" +import { cn } from "@/lib/utils" +import { ChevronDown, Circle } from "lucide-react" +import React from "react" + +export type ChainOfThoughtItemProps = React.ComponentProps<"div"> + +export const ChainOfThoughtItem = ({ + children, + className, + ...props +}: ChainOfThoughtItemProps) => ( +
    + {children} +
    +) + +export type ChainOfThoughtTriggerProps = React.ComponentProps< + typeof CollapsibleTrigger +> & { + leftIcon?: React.ReactNode + swapIconOnHover?: boolean +} + +export const ChainOfThoughtTrigger = ({ + children, + className, + leftIcon, + swapIconOnHover = true, + ...props +}: ChainOfThoughtTriggerProps) => ( + +
    + {leftIcon ? ( + + + {leftIcon} + + {swapIconOnHover && ( + + )} + + ) : ( + + + + )} + {children} +
    + {!leftIcon && ( + + )} +
    +) + +export type ChainOfThoughtContentProps = React.ComponentProps< + typeof CollapsibleContent +> + +export const ChainOfThoughtContent = ({ + children, + className, + ...props +}: ChainOfThoughtContentProps) => { + return ( + +
    +
    +
    +
    {children}
    +
    + + ) +} + +export type ChainOfThoughtProps = { + children: React.ReactNode + className?: string +} + +export function ChainOfThought({ children, className }: ChainOfThoughtProps) { + const childrenArray = React.Children.toArray(children) + + return ( +
    + {childrenArray.map((child, index) => ( + + {React.isValidElement(child) && + React.cloneElement( + child as React.ReactElement, + { + isLast: index === childrenArray.length - 1, + } + )} + + ))} +
    + ) +} + +export type ChainOfThoughtStepProps = { + children: React.ReactNode + className?: string + isLast?: boolean +} + +export const ChainOfThoughtStep = ({ + children, + className, + isLast = false, + ...props +}: ChainOfThoughtStepProps & React.ComponentProps) => { + return ( + + {children} +
    +
    +
    + + ) +} diff --git a/apps/app/src/components/ui/code-block.tsx b/apps/app/src/components/ui/code-block.tsx new file mode 100644 index 0000000000..07bc58d986 --- /dev/null +++ b/apps/app/src/components/ui/code-block.tsx @@ -0,0 +1,103 @@ +import { cn } from "@/lib/utils" +import React, { useEffect, useState } from "react" +import { codeToHtml } from "shiki" + +export type CodeBlockProps = { + children?: React.ReactNode + className?: string +} & React.HTMLProps + +function CodeBlock({ children, className, ...props }: CodeBlockProps) { + return ( +
    + {children} +
    + ) +} + +export type CodeBlockCodeProps = { + code: string + language?: string + theme?: string + className?: string +} & React.HTMLProps + +function CodeBlockCode({ + code, + language = "tsx", + theme = "github-light", + className, + ...props +}: CodeBlockCodeProps) { + const [highlightedHtml, setHighlightedHtml] = useState(null) + + useEffect(() => { + let cancelled = false + + async function highlight() { + if (!code) { + if (!cancelled) { + setHighlightedHtml("
    ") + } + return + } + + const html = await codeToHtml(code, { lang: language, theme }) + if (!cancelled) { + setHighlightedHtml(html) + } + } + + highlight() + + return () => { + cancelled = true + } + }, [code, language, theme]) + + const classNames = cn( + "w-full overflow-x-auto text-[13px] [&>pre]:px-4 [&>pre]:py-4", + className + ) + + // SSR fallback: render plain code if not hydrated yet + return highlightedHtml ? ( +
    + ) : ( +
    +
    +        {code}
    +      
    +
    + ) +} + +export type CodeBlockGroupProps = React.HTMLAttributes + +function CodeBlockGroup({ + children, + className, + ...props +}: CodeBlockGroupProps) { + return ( +
    + {children} +
    + ) +} + +export { CodeBlockGroup, CodeBlockCode, CodeBlock } diff --git a/apps/app/src/components/ui/hover-card.tsx b/apps/app/src/components/ui/hover-card.tsx new file mode 100644 index 0000000000..73ea5c097c --- /dev/null +++ b/apps/app/src/components/ui/hover-card.tsx @@ -0,0 +1,49 @@ +import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card" + +import { cn } from "@/lib/utils" + +function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) { + return +} + +function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) { + return ( + + ) +} + +function HoverCardContent({ + className, + side = "bottom", + sideOffset = 4, + align = "center", + alignOffset = 4, + ...props +}: PreviewCardPrimitive.Popup.Props & + Pick< + PreviewCardPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + + + ) +} + +export { HoverCard, HoverCardTrigger, HoverCardContent } diff --git a/apps/app/src/components/ui/image.tsx b/apps/app/src/components/ui/image.tsx new file mode 100644 index 0000000000..70e00bda9f --- /dev/null +++ b/apps/app/src/components/ui/image.tsx @@ -0,0 +1,76 @@ +import { cn } from "@/lib/utils" +import * as React from "react" + +export type GeneratedImageLike = { + src?: string + base64?: string + uint8Array?: Uint8Array + mediaType?: string +} + +export type ImageProps = GeneratedImageLike & + React.ComponentProps<"img"> & { + alt: string + } + +function getImageSrc({ + base64, + mediaType, +}: Pick) { + if (base64 && mediaType) { + return `data:${mediaType};base64,${base64}` + } + return undefined +} + +export const Image = ({ + src, + base64, + uint8Array, + mediaType = "image/png", + className, + alt, + ...props +}: ImageProps) => { + const [objectUrl, setObjectUrl] = React.useState(undefined) + + React.useEffect(() => { + if (uint8Array && mediaType) { + const blob = new Blob([uint8Array as BlobPart], { type: mediaType }) + const url = URL.createObjectURL(blob) + setObjectUrl(url) + return () => { + URL.revokeObjectURL(url) + } + } + setObjectUrl(undefined) + return + }, [uint8Array, mediaType]) + + const base64Src = getImageSrc({ base64, mediaType }) + const imageSrc = src ?? base64Src ?? objectUrl + + if (!imageSrc) { + return ( +
    + ) + } + + return ( + {alt} + ) +} diff --git a/apps/app/src/components/ui/markdown.tsx b/apps/app/src/components/ui/markdown.tsx new file mode 100644 index 0000000000..fdbc89991b --- /dev/null +++ b/apps/app/src/components/ui/markdown.tsx @@ -0,0 +1,110 @@ +import { cn } from "@/lib/utils" +import { marked } from "marked" +import { memo, useId, useMemo } from "react" +import ReactMarkdown, { Components } from "react-markdown" +import remarkBreaks from "remark-breaks" +import remarkGfm from "remark-gfm" +import { CodeBlock, CodeBlockCode } from "./code-block" + +export type MarkdownProps = { + children: string + id?: string + className?: string + components?: Partial +} + +function parseMarkdownIntoBlocks(markdown: string): string[] { + const tokens = marked.lexer(markdown) + return tokens.map((token) => token.raw) +} + +function extractLanguage(className?: string): string { + if (!className) return "plaintext" + const match = className.match(/language-(\w+)/) + return match ? match[1] : "plaintext" +} + +const INITIAL_COMPONENTS: Partial = { + code: function CodeComponent({ className, children, ...props }) { + const isInline = + !props.node?.position?.start.line || + props.node?.position?.start.line === props.node?.position?.end.line + + if (isInline) { + return ( + + {children} + + ) + } + + const language = extractLanguage(className) + + return ( + + + + ) + }, + pre: function PreComponent({ children }) { + return <>{children} + }, +} + +const MemoizedMarkdownBlock = memo( + function MarkdownBlock({ + content, + components = INITIAL_COMPONENTS, + }: { + content: string + components?: Partial + }) { + return ( + + {content} + + ) + }, + function propsAreEqual(prevProps, nextProps) { + return prevProps.content === nextProps.content + } +) + +MemoizedMarkdownBlock.displayName = "MemoizedMarkdownBlock" + +function MarkdownComponent({ + children, + id, + className, + components = INITIAL_COMPONENTS, +}: MarkdownProps) { + const generatedId = useId() + const blockId = id ?? generatedId + const blocks = useMemo(() => parseMarkdownIntoBlocks(children), [children]) + + return ( +
    + {blocks.map((block, index) => ( + + ))} +
    + ) +} + +const Markdown = memo(MarkdownComponent) +Markdown.displayName = "Markdown" + +export { Markdown } diff --git a/apps/app/src/components/ui/message.tsx b/apps/app/src/components/ui/message.tsx new file mode 100644 index 0000000000..345b600557 --- /dev/null +++ b/apps/app/src/components/ui/message.tsx @@ -0,0 +1,127 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { MarkdownBlock } from "@/components/markdown/markdown" +import { cn } from "@/lib/utils" +import { motion } from "motion/react" + +const messageContentClassName = + "rounded-lg p-2 text-foreground leading-relaxed bg-secondary prose wrap-break-word whitespace-normal" + +export type MessageProps = { + children: React.ReactNode + className?: string +} & React.HTMLProps + +const Message = ({ children, className, ...props }: MessageProps) => ( +
    + {children} +
    +) + +export type MessageAvatarProps = { + src: string + alt: string + fallback?: string + delayMs?: number + className?: string +} + +const MessageAvatar = ({ + src, + alt, + fallback, + delayMs, + className, +}: MessageAvatarProps) => { + return ( + + + {fallback && ( + {fallback} + )} + + ) +} + +export type MessageContentProps = { + children: React.ReactNode + markdown?: boolean + isStreaming?: boolean + className?: string +} & React.ComponentProps + +const MessageContent = ({ + children, + markdown = false, + className, + isStreaming, + ...props +}: MessageContentProps) => { + if (markdown) { + return ( + + ) + } + + return ( + + {children} + + ) +} + +export type MessageActionsProps = { + children: React.ReactNode + className?: string +} & React.HTMLProps + +const MessageActions = ({ + children, + className, + ...props +}: MessageActionsProps) => ( +
    + {children} +
    +) + +export type MessageActionProps = { + className?: string + tooltip: React.ReactNode + children: React.ReactElement + side?: "top" | "bottom" | "left" | "right" +} & React.ComponentProps + +const MessageAction = ({ + tooltip, + children, + className, + side = "top", + ...props +}: MessageActionProps) => { + return ( + + + + {tooltip} + + + ) +} + +export { Message, MessageAvatar, MessageContent, MessageActions, MessageAction } diff --git a/apps/app/src/components/ui/reasoning.tsx b/apps/app/src/components/ui/reasoning.tsx new file mode 100644 index 0000000000..3adff6a022 --- /dev/null +++ b/apps/app/src/components/ui/reasoning.tsx @@ -0,0 +1,174 @@ +import { cn } from "@/lib/utils" +import { ChevronDownIcon } from "lucide-react" +import React, { + createContext, + useContext, + useEffect, + useRef, + useState, +} from "react" +import { MarkdownBlock } from "../markdown/markdown" + +type ReasoningContextType = { + isOpen: boolean + onOpenChange: (open: boolean) => void +} + +const ReasoningContext = createContext( + undefined +) + +function useReasoningContext() { + const context = useContext(ReasoningContext) + if (!context) { + throw new Error( + "useReasoningContext must be used within a Reasoning provider" + ) + } + return context +} + +export type ReasoningProps = { + children: React.ReactNode + className?: string + open?: boolean + onOpenChange?: (open: boolean) => void + isStreaming?: boolean +} +function Reasoning({ + children, + className, + open, + onOpenChange, + isStreaming, +}: ReasoningProps) { + const [internalOpen, setInternalOpen] = useState(false) + const [wasAutoOpened, setWasAutoOpened] = useState(false) + + const isControlled = open !== undefined + const isOpen = isControlled ? open : internalOpen + + const handleOpenChange = (newOpen: boolean) => { + if (!isControlled) { + setInternalOpen(newOpen) + } + onOpenChange?.(newOpen) + } + + useEffect(() => { + if (isStreaming && !wasAutoOpened) { + if (!isControlled) setInternalOpen(true) + setWasAutoOpened(true) + } + + if (!isStreaming && wasAutoOpened) { + if (!isControlled) setInternalOpen(false) + setWasAutoOpened(false) + } + }, [isStreaming, wasAutoOpened, isControlled]) + + return ( + +
    {children}
    +
    + ) +} + +export type ReasoningTriggerProps = { + children: React.ReactNode + className?: string +} & React.HTMLAttributes + +function ReasoningTrigger({ + children, + className, + ...props +}: ReasoningTriggerProps) { + const { isOpen, onOpenChange } = useReasoningContext() + + return ( + + ) +} + +export type ReasoningContentProps = { + children: React.ReactNode + className?: string + markdown?: boolean + contentClassName?: string +} & React.HTMLAttributes + +function ReasoningContent({ + children, + className, + contentClassName, + markdown = false, + ...props +}: ReasoningContentProps) { + const contentRef = useRef(null) + const innerRef = useRef(null) + const { isOpen } = useReasoningContext() + + useEffect(() => { + if (!contentRef.current || !innerRef.current) return + + const observer = new ResizeObserver(() => { + if (contentRef.current && innerRef.current && isOpen) { + contentRef.current.style.maxHeight = `${innerRef.current.scrollHeight}px` + } + }) + + observer.observe(innerRef.current) + + if (isOpen) { + contentRef.current.style.maxHeight = `${innerRef.current.scrollHeight}px` + } + + return () => observer.disconnect() + }, [isOpen]) + + return ( +
    +
    + {markdown ? : children} +
    +
    + ) +} + +export { Reasoning, ReasoningTrigger, ReasoningContent } diff --git a/apps/app/src/components/ui/response-stream.tsx b/apps/app/src/components/ui/response-stream.tsx new file mode 100644 index 0000000000..997748b2bc --- /dev/null +++ b/apps/app/src/components/ui/response-stream.tsx @@ -0,0 +1,394 @@ +import { cn } from "@/lib/utils" +import React, { useCallback, useEffect, useRef, useState } from "react" + +export type Mode = "typewriter" | "fade" + +export type UseTextStreamOptions = { + textStream: string | AsyncIterable + speed?: number + mode?: Mode + onComplete?: () => void + fadeDuration?: number + segmentDelay?: number + characterChunkSize?: number + onError?: (error: unknown) => void +} + +export type UseTextStreamResult = { + displayedText: string + isComplete: boolean + segments: { text: string; index: number }[] + getFadeDuration: () => number + getSegmentDelay: () => number + reset: () => void + startStreaming: () => void + pause: () => void + resume: () => void +} + +function useTextStream({ + textStream, + speed = 20, + mode = "typewriter", + onComplete, + fadeDuration, + segmentDelay, + characterChunkSize, + onError, +}: UseTextStreamOptions): UseTextStreamResult { + const [displayedText, setDisplayedText] = useState("") + const [isComplete, setIsComplete] = useState(false) + const [segments, setSegments] = useState<{ text: string; index: number }[]>( + [] + ) + + const speedRef = useRef(speed) + const modeRef = useRef(mode) + const currentIndexRef = useRef(0) + const animationRef = useRef(null) + const fadeDurationRef = useRef(fadeDuration) + const segmentDelayRef = useRef(segmentDelay) + const characterChunkSizeRef = useRef(characterChunkSize) + const streamRef = useRef(null) + const completedRef = useRef(false) + const onCompleteRef = useRef(onComplete) + const onErrorRef = useRef(onError) + + useEffect(() => { + speedRef.current = speed + modeRef.current = mode + fadeDurationRef.current = fadeDuration + segmentDelayRef.current = segmentDelay + characterChunkSizeRef.current = characterChunkSize + }, [speed, mode, fadeDuration, segmentDelay, characterChunkSize]) + + useEffect(() => { + onCompleteRef.current = onComplete + onErrorRef.current = onError + }, [onComplete, onError]) + + const getChunkSize = useCallback(() => { + if (typeof characterChunkSizeRef.current === "number") { + return Math.max(1, characterChunkSizeRef.current) + } + + const normalizedSpeed = Math.min(100, Math.max(1, speedRef.current)) + + if (modeRef.current === "typewriter") { + if (normalizedSpeed < 25) return 1 + return Math.max(1, Math.round((normalizedSpeed - 25) / 10)) + } else if (modeRef.current === "fade") { + return 1 + } + + return 1 + }, []) + + const getProcessingDelay = useCallback(() => { + if (typeof segmentDelayRef.current === "number") { + return Math.max(0, segmentDelayRef.current) + } + + const normalizedSpeed = Math.min(100, Math.max(1, speedRef.current)) + return Math.max(1, Math.round(100 / Math.sqrt(normalizedSpeed))) + }, []) + + const getFadeDuration = useCallback(() => { + if (typeof fadeDurationRef.current === "number") + return Math.max(10, fadeDurationRef.current) + + const normalizedSpeed = Math.min(100, Math.max(1, speedRef.current)) + return Math.round(1000 / Math.sqrt(normalizedSpeed)) + }, []) + + const getSegmentDelay = useCallback(() => { + if (typeof segmentDelayRef.current === "number") + return Math.max(0, segmentDelayRef.current) + + const normalizedSpeed = Math.min(100, Math.max(1, speedRef.current)) + return Math.max(1, Math.round(100 / Math.sqrt(normalizedSpeed))) + }, []) + + const updateSegments = useCallback((text: string) => { + if (modeRef.current === "fade") { + try { + const segmenter = new Intl.Segmenter(navigator.language, { + granularity: "word", + }) + const segmentIterator = segmenter.segment(text) + const newSegments = Array.from(segmentIterator).map( + (segment, index) => ({ + text: segment.segment, + index, + }) + ) + setSegments(newSegments) + } catch (error) { + const newSegments = text + .split(/(\s+)/) + .filter(Boolean) + .map((word, index) => ({ + text: word, + index, + })) + setSegments(newSegments) + onErrorRef.current?.(error) + } + } + }, []) + + const markComplete = useCallback(() => { + if (!completedRef.current) { + completedRef.current = true + setIsComplete(true) + onCompleteRef.current?.() + } + }, []) + + const reset = useCallback(() => { + currentIndexRef.current = 0 + setDisplayedText("") + setSegments([]) + setIsComplete(false) + completedRef.current = false + + if (animationRef.current) { + cancelAnimationFrame(animationRef.current) + animationRef.current = null + } + }, []) + + const processStringTypewriter = useCallback( + (text: string) => { + let lastFrameTime = 0 + + const streamContent = (timestamp: number) => { + const delay = getProcessingDelay() + if (delay > 0 && timestamp - lastFrameTime < delay) { + animationRef.current = requestAnimationFrame(streamContent) + return + } + lastFrameTime = timestamp + + if (currentIndexRef.current >= text.length) { + markComplete() + return + } + + const chunkSize = getChunkSize() + const endIndex = Math.min( + currentIndexRef.current + chunkSize, + text.length + ) + const newDisplayedText = text.slice(0, endIndex) + + setDisplayedText(newDisplayedText) + if (modeRef.current === "fade") { + updateSegments(newDisplayedText) + } + + currentIndexRef.current = endIndex + + if (endIndex < text.length) { + animationRef.current = requestAnimationFrame(streamContent) + } else { + markComplete() + } + } + + animationRef.current = requestAnimationFrame(streamContent) + }, + [getProcessingDelay, getChunkSize, updateSegments, markComplete] + ) + + const processAsyncIterable = useCallback( + async (stream: AsyncIterable) => { + const controller = new AbortController() + streamRef.current = controller + + let displayed = "" + + try { + for await (const chunk of stream) { + if (controller.signal.aborted) return + + displayed += chunk + setDisplayedText(displayed) + updateSegments(displayed) + } + + markComplete() + } catch (error) { + console.error("Error processing text stream:", error) + markComplete() + onErrorRef.current?.(error) + } + }, + [updateSegments, markComplete] + ) + + const startStreaming = useCallback(() => { + reset() + + if (typeof textStream === "string") { + processStringTypewriter(textStream) + } else if (textStream) { + processAsyncIterable(textStream) + } + }, [textStream, reset, processStringTypewriter, processAsyncIterable]) + + const pause = useCallback(() => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current) + animationRef.current = null + } + }, []) + + const resume = useCallback(() => { + if (typeof textStream === "string" && !isComplete) { + processStringTypewriter(textStream) + } + }, [textStream, isComplete, processStringTypewriter]) + + useEffect(() => { + startStreaming() + + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current) + } + if (streamRef.current) { + streamRef.current.abort() + } + } + }, [textStream, startStreaming]) + + return { + displayedText, + isComplete, + segments, + getFadeDuration, + getSegmentDelay, + reset, + startStreaming, + pause, + resume, + } +} + +export type ResponseStreamProps = { + textStream: string | AsyncIterable + mode?: Mode + speed?: number // 1-100, where 1 is slowest and 100 is fastest + className?: string + onComplete?: () => void + as?: keyof React.JSX.IntrinsicElements // Element type to render + fadeDuration?: number // Custom fade duration in ms (overrides speed) + segmentDelay?: number // Custom delay between segments in ms (overrides speed) + characterChunkSize?: number // Custom characters per frame for typewriter mode (overrides speed) +} + +function ResponseStream({ + textStream, + mode = "typewriter", + speed = 20, + className = "", + onComplete, + as = "div", + fadeDuration, + segmentDelay, + characterChunkSize, +}: ResponseStreamProps) { + const animationEndRef = useRef<(() => void) | null>(null) + + const { + displayedText, + isComplete, + segments, + getFadeDuration, + getSegmentDelay, + } = useTextStream({ + textStream, + speed, + mode, + onComplete, + fadeDuration, + segmentDelay, + characterChunkSize, + }) + + useEffect(() => { + animationEndRef.current = onComplete ?? null + }, [onComplete]) + + const handleLastSegmentAnimationEnd = useCallback(() => { + if (animationEndRef.current && isComplete) { + animationEndRef.current() + } + }, [isComplete]) + + // fadeStyle is the style for the fade animation + const fadeStyle = ` + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + + .fade-segment { + display: inline-block; + opacity: 0; + animation: fadeIn ${getFadeDuration()}ms ease-out forwards; + } + + .fade-segment-space { + white-space: pre; + } + ` + + const renderContent = () => { + switch (mode) { + case "typewriter": + return <>{displayedText} + + case "fade": + return ( + <> + +
    + {segments.map((segment, idx) => { + const isWhitespace = /^\s+$/.test(segment.text) + const isLastSegment = idx === segments.length - 1 + + return ( + + {segment.text} + + ) + })} +
    + + ) + + default: + return <>{displayedText} + } + } + + const Container = as as keyof React.JSX.IntrinsicElements + + return {renderContent()} +} + +export { useTextStream, ResponseStream } diff --git a/apps/app/src/components/ui/source.tsx b/apps/app/src/components/ui/source.tsx new file mode 100644 index 0000000000..59d127c5f3 --- /dev/null +++ b/apps/app/src/components/ui/source.tsx @@ -0,0 +1,119 @@ +"use client" + +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card" +import { cn } from "@/lib/utils" +import { createContext, useContext } from "react" + +const SourceContext = createContext<{ + href: string + domain: string +} | null>(null) + +function useSourceContext() { + const ctx = useContext(SourceContext) + if (!ctx) throw new Error("Source.* must be used inside ") + return ctx +} + +export type SourceProps = { + href: string + children: React.ReactNode +} + +export function Source({ href, children }: SourceProps) { + let domain = "" + try { + domain = new URL(href).hostname + } catch { + domain = href.split("/").pop() || href + } + + return ( + + + {children} + + + ) +} + +export type SourceTriggerProps = { + label?: string | number + showFavicon?: boolean + className?: string +} + +export function SourceTrigger({ + label, + showFavicon = false, + className, +}: SourceTriggerProps) { + const { href, domain } = useSourceContext() + const labelToShow = label ?? domain.replace("www.", "") + + return ( + }>{showFavicon && ( + favicon + )}{labelToShow} + ) +} + +export type SourceContentProps = { + title: string + description?: string + className?: string +} + +export function SourceContent({ + title, + description, + className, +}: SourceContentProps) { + const { href, domain } = useSourceContext() + + return ( + + +
    + favicon +
    + {domain.replace("www.", "")} +
    +
    +
    {title}
    +
    + {description ? description : No description available} +
    +
    +
    + ) +} diff --git a/apps/app/src/components/ui/steps.tsx b/apps/app/src/components/ui/steps.tsx new file mode 100644 index 0000000000..638bbda0f1 --- /dev/null +++ b/apps/app/src/components/ui/steps.tsx @@ -0,0 +1,120 @@ +"use client" + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" +import { cn } from "@/lib/utils" +import { ChevronDown } from "lucide-react" + +export type StepsItemProps = React.ComponentProps<"div"> + +export const StepsItem = ({ + children, + className, + ...props +}: StepsItemProps) => ( +
    + {children} +
    +) + +export type StepsTriggerProps = React.ComponentProps< + typeof CollapsibleTrigger +> & { + leftIcon?: React.ReactNode + swapIconOnHover?: boolean +} + +export const StepsTrigger = ({ + children, + className, + leftIcon, + swapIconOnHover = true, + ...props +}: StepsTriggerProps) => ( + +
    + {leftIcon ? ( + + + {leftIcon} + + {swapIconOnHover && ( + + )} + + ) : null} + {children} +
    + {!leftIcon && ( + + )} +
    +) + +export type StepsContentProps = React.ComponentProps< + typeof CollapsibleContent +> & { + bar?: React.ReactNode +} + +export const StepsContent = ({ + children, + className, + bar, + ...props +}: StepsContentProps) => { + return ( + + {bar ? ( +
    +
    {bar}
    +
    {children}
    +
    + ) : ( +
    {children}
    + )} +
    + ) +} + +export type StepsBarProps = React.HTMLAttributes + +export const StepsBar = ({ className, ...props }: StepsBarProps) => ( +
    +) + +export type StepsProps = React.ComponentProps + +export function Steps({ defaultOpen = true, className, ...props }: StepsProps) { + return ( + + ) +} diff --git a/apps/app/src/components/ui/tool.tsx b/apps/app/src/components/ui/tool.tsx new file mode 100644 index 0000000000..cb3783721a --- /dev/null +++ b/apps/app/src/components/ui/tool.tsx @@ -0,0 +1,196 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" +import { cn } from "@/lib/utils" +import { + CheckCircle, + ChevronDown, + Loader2, + Settings, + XCircle, +} from "lucide-react" +import { useState } from "react" +import type { DynamicToolUIPart, ToolUIPart } from "ai" + +export type ToolPart = ToolUIPart | DynamicToolUIPart + +export type ToolProps = { + title?: string + toolPart: ToolPart + defaultOpen?: boolean + className?: string +} + +const Tool = ({ title, toolPart, defaultOpen = false, className }: ToolProps) => { + const [isOpen, setIsOpen] = useState(defaultOpen) + + const { state, input, output, toolCallId } = toolPart + + const getStateIcon = () => { + switch (state) { + case "input-streaming": + return + case "input-available": + return + case "output-available": + return + case "output-error": + return + default: + return + } + } + + const getStateBadge = () => { + const baseClasses = "px-2 py-1 rounded-full text-xs font-medium" + switch (state) { + case "input-streaming": + return ( + + Processing + + ) + case "input-available": + return ( + + Ready + + ) + case "output-available": + return ( + + Completed + + ) + case "output-error": + return ( + + Error + + ) + default: + return ( + + Pending + + ) + } + } + + const formatValue = (value: unknown): string => { + if (value === null) return "null" + if (value === undefined) return "undefined" + if (typeof value === "string") return value + if (typeof value === "object") { + return JSON.stringify(value, null, 2) + } + return String(value) + } + + const toolName = + toolPart.type === "dynamic-tool" ? toolPart.toolName : toolPart.type + + const hasInput = input !== null && input !== undefined + const hasOutput = "output" in toolPart && toolPart.output !== undefined + + return ( +
    + + }>
    + {getStateIcon()} + + {title || toolName} + + {getStateBadge()} +
    + +
    + {hasInput && ( +
    +

    + Input +

    +
    +
    +                    {formatValue(input)}
    +                  
    +
    +
    + )} + + {hasOutput && ( +
    +

    + Output +

    +
    +
    +                    {formatValue(toolPart.output)}
    +                  
    +
    +
    + )} + + {state === "output-error" && toolPart.errorText && ( +
    +

    Error

    +
    + {toolPart.errorText} +
    +
    + )} + + {state === "input-streaming" && ( +
    + Processing tool call... +
    + )} + + {toolCallId && ( +
    + Call ID: {toolCallId} +
    + )} +
    +
    +
    +
    + ) +} + +export { Tool } diff --git a/apps/app/src/lib/artifacts.ts b/apps/app/src/lib/artifacts.ts new file mode 100644 index 0000000000..c41427da30 --- /dev/null +++ b/apps/app/src/lib/artifacts.ts @@ -0,0 +1,278 @@ +import type { UIMessage } from "ai"; +import * as React from "react"; + +import { + isApplyPatchToolPart, + isEditToolPart, + isWriteToolPart, +} from "@/lib/build-in-tools"; +import { useOpenTargets } from "@/lib/target-provider"; +import type { OpenTarget, OpenTargetPreview } from "@/react-app/domains/session/artifacts/open-target"; + +export type ArtifactType = "website" | "markdown" | "sheet" | "slides" | "document" | "image" | "video" | "audio" | "pdf" | "html" | "text" | "unknown"; + +export type ArtifactItem = { + id: string + name: string + path: string + type: ArtifactType + messageId: string + legacy_target: OpenTarget +} + +const WORKSPACES_PREFIX_PATTERN = /^workspaces\/[^/]+\//i; +const WORKSPACE_ID_PREFIX_PATTERN = /^workspace\/(?:ws_[^/]+|\d+|[0-9a-f-]{6,})\//i; + +export function isMarkdownPreviewSupported(extension: string) { + return ["md", "markdown", "mdx"].includes(extension); +} + +export function isSheetPreviewSupported(extension: string) { + return ["csv", "tsv", "xlsx", "xls", "ods"].includes(extension); +} + +export function isImagePreviewSupported(extension: string) { + return ["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(extension); +} + +export function isPdfPreviewSupported(extension: string) { + return ["pdf"].includes(extension); +} + +export function isHtmlPreviewSupported(extension: string) { + return ["html", "htm"].includes(extension); +} + +export function isTextPreviewSupported(extension: string) { + return ["txt", "log", "json", "jsonc", "yaml", "yml", "toml", "xml", "ts", "tsx", "js", "jsx", "css", "scss"].includes(extension); +} + +export function isPreviewSupported(extension: string) { + return isMarkdownPreviewSupported(extension) || isSheetPreviewSupported(extension) || isImagePreviewSupported(extension) || isPdfPreviewSupported(extension) || isHtmlPreviewSupported(extension) || isTextPreviewSupported(extension); +} + +export function getArtifactType(filename: string): ArtifactType { + const extension = getFileExtension(filename); + + if (!extension) { + return "unknown"; + } + + if (["md", "markdown", "mdx", "rmd", "rst"].includes(extension)) { + return "markdown"; + } + + if (["csv", "tsv", "xlsx", "xls", "xlsm", "xlsb", "ods", "numbers"].includes(extension)) { + return "sheet"; + } + + if (["ppt", "pptx", "pptm", "pot", "potx", "odp", "key", "sxi"].includes(extension)) { + return "slides"; + } + + if (["doc", "docx", "odt", "rtf", "pages"].includes(extension)) { + return "document"; + } + + if (["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif", "heic", "heif", "tif", "tiff"].includes(extension)) { + return "image"; + } + + if (["mp4", "mov", "avi", "mkv", "webm", "wmv", "flv", "m4v", "ogv", "mpeg", "mpg", "3gp"].includes(extension)) { + return "video"; + } + + if (["mp3", "wav", "flac", "aac", "ogg", "oga", "m4a", "wma", "opus", "aiff", "aif", "mid", "midi"].includes(extension)) { + return "audio"; + } + + if (["pdf"].includes(extension)) { + return "pdf"; + } + + if (["html", "htm", "xhtml"].includes(extension)) { + return "html"; + } + + if (["txt", "log", "json", "jsonc", "json5", "yaml", "yml", "toml", "xml", "ini", "env", "ts", "tsx", "js", "jsx", "mjs", "cjs", "vue", "svelte", "css", "scss", "sass", "less", "py", "rb", "go", "rs", "java", "kt", "swift", "php", "c", "cpp", "h", "cs", "sql", "sh", "bash", "zsh"].includes(extension)) { + return "text"; + } + + return "unknown"; +} + +export function getFileExtension(filename: string) { + return filename.split(".").pop()?.toLowerCase(); +} + +const ARTIFACT_TYPE_LABELS: Record = { + website: "Website", + markdown: "Markdown", + sheet: "Spreadsheet", + slides: "Slides", + document: "Document", + image: "Image", + video: "Video", + audio: "Audio", + pdf: "PDF", + html: "HTML", + text: "Text", + unknown: "File", +}; + +export function getArtifactTypeLabel(type: ArtifactType) { + return ARTIFACT_TYPE_LABELS[type]; +} + +function getArtifactName(path: string) { + const segments = path.split(/[/\\]/); + + return segments[segments.length - 1] ?? path; +} + +function normalizeArtifactPath(path: string) { + return path + .trim() + .replace(/[\\]+/g, "/") + .replace(/^\.\//, "") + .replace(WORKSPACES_PREFIX_PATTERN, "") + .replace(WORKSPACE_ID_PREFIX_PATTERN, ""); +} + +function artifactTypeToPreview(type: ArtifactType): OpenTargetPreview { + if (type === "markdown") return "markdown"; + if (type === "sheet") return "sheet"; + if (type === "image") return "image"; + if (type === "pdf") return "pdf"; + if (type === "html") return "html"; + if (type === "text") return "text"; + if (type === "website") return "browser"; + return "external"; +} + +function artifactPathMatchesTarget(path: string, targetValue: string) { + const normalized = normalizeArtifactPath(path).toLowerCase(); + const target = normalizeArtifactPath(targetValue).toLowerCase(); + return normalized === target || normalized.endsWith(`/${target}`); +} + +function openTargetFromArtifactPath( + path: string, + name: string, + type: ArtifactType, + verifiedTargets: OpenTarget[], +): OpenTarget { + const normalized = normalizeArtifactPath(path); + const id = `file:${normalized.toLowerCase()}`; + const verified = verifiedTargets.find( + (target) => target.id === id || artifactPathMatchesTarget(normalized, target.value), + ); + + return verified ?? { + id, + kind: "file", + value: normalized, + name, + preview: artifactTypeToPreview(type), + confidence: 95, + reason: "artifact", + }; +} + +function parseApplyPatchPaths(patchText: string) { + const paths: string[] = []; + + for (const line of patchText.split("\n")) { + if (line.startsWith("*** Add File:")) { + paths.push(line.slice("*** Add File:".length).trim()); + continue; + } + + if (line.startsWith("*** Update File:")) { + paths.push(line.slice("*** Update File:".length).trim()); + continue; + } + + if (line.startsWith("*** Move to:")) { + paths.push(line.slice("*** Move to:".length).trim()); + } + } + + return paths; +} + +function getArtifactPathsFromMessage(message: UIMessage) { + const paths: (string | undefined)[] = []; + + for (const part of message.parts) { + if (part.type === "source-document") { + if (part.filename) { + paths.push(part.filename); + } else { + paths.push(part.title); + } + continue; + } + + if (part.type !== "dynamic-tool" || part.state !== "output-available") { + continue; + } + + if (isWriteToolPart(part)) { + paths.push(part.input.filePath); + continue; + } + + if (isEditToolPart(part)) { + paths.push(part.input.filePath); + continue; + } + if (isApplyPatchToolPart(part)) { + paths.push(...parseApplyPatchPaths(part.input.patchText)); + } + } + + return paths.map((path) => path?.trim().toLowerCase()).filter((path) => path) as string[]; +} + +export function getArtifactsFromMessages(messages: UIMessage[], openTargets: OpenTarget[] = []) { + const artifacts = new Map(); + + for (const message of messages) { + for (const path of getArtifactPathsFromMessage(message)) { + const name = getArtifactName(path); + const type = getArtifactType(path); + artifacts.set(path, { + id: path, + name, + path, + type, + messageId: message.id, + legacy_target: openTargetFromArtifactPath(path, name, type, openTargets), + }); + } + } + + return [...artifacts.values()]; +} + +export function useArtifacts(messages: UIMessage[]) { + const { openTargets } = useOpenTargets(); + + return React.useMemo( + () => getArtifactsFromMessages(messages, openTargets), + [messages, openTargets], + ); +} + +export function usePreviewArtifact() { + const { onOpenTarget } = useOpenTargets(); + + return React.useCallback((artifact: ArtifactItem) => { + async function previewArtifact() { + onOpenTarget?.(artifact.legacy_target); + } + + void previewArtifact(); + }, [onOpenTarget]); +} diff --git a/apps/app/src/lib/build-in-tools.ts b/apps/app/src/lib/build-in-tools.ts new file mode 100644 index 0000000000..29edb197ac --- /dev/null +++ b/apps/app/src/lib/build-in-tools.ts @@ -0,0 +1,370 @@ +import type { ToolUIPart, DynamicToolUIPart } from "ai"; + +export interface ToolMetadata { + truncated?: boolean; + outputPath?: string; +} + +export interface InvalidInput { + tool: string; + error: string; +} + +export interface InvalidMetadata extends ToolMetadata {} + +export interface QuestionOption { + label: string; + description: string; +} + +export interface QuestionPrompt { + question: string; + header: string; + options: QuestionOption[]; + multiple?: boolean; +} + +export interface QuestionInput { + questions: QuestionPrompt[]; +} + +export interface QuestionMetadata extends ToolMetadata { + answers: string[][]; +} + +export interface BashInput { + command: string; + timeout?: number; + workdir?: string; + description: string; +} + +export interface BashMetadata extends ToolMetadata { + output: string; + exit: number | null; + description: string; + truncated: boolean; +} + +export interface ReadInput { + filePath: string; + offset?: number; + limit?: number; +} + +export interface ReadMetadata extends ToolMetadata { + preview: string; + truncated: boolean; + loaded: string[]; +} + +export interface GlobInput { + pattern: string; + path?: string; +} + +export interface GlobMetadata extends ToolMetadata { + count: number; + truncated: boolean; +} + +export interface GrepInput { + pattern: string; + path?: string; + include?: string; +} + +export interface GrepMetadata extends ToolMetadata { + matches: number; + truncated: boolean; +} + +export interface FileDiff { + file: string; + patch: string; + additions: number; + deletions: number; +} + +export interface EditInput { + filePath: string; + oldString: string; + newString: string; + replaceAll?: boolean; +} + +export interface EditMetadata extends ToolMetadata { + diagnostics: unknown; + diff: string; + filediff: FileDiff; +} + +export interface WriteInput { + content: string; + filePath: string; +} + +export interface WriteMetadata extends ToolMetadata { + diagnostics: unknown; + filepath: string; + exists: boolean; +} + +export interface TaskModel { + modelID: string; + providerID: string; +} + +export interface TaskInput { + description: string; + prompt: string; + subagent_type: string; + task_id?: string; + command?: string; + background?: boolean; +} + +export interface TaskMetadata extends ToolMetadata { + parentSessionId: string; + sessionId: string; + model: TaskModel; + background?: true; + jobId?: string; +} + +export type TaskStatusState = "running" | "completed" | "error" | "cancelled"; + +export interface TaskStatusInput { + task_id: string; + wait?: boolean; + timeout_ms?: number; +} + +export interface TaskStatusMetadata extends ToolMetadata { + task_id: string; + state: TaskStatusState; + timed_out: boolean; +} + +export type WebFetchFormat = "text" | "markdown" | "html"; + +export interface WebFetchInput { + url: string; + format?: WebFetchFormat; + timeout?: number; +} + +export interface WebFetchMetadata extends ToolMetadata {} + +export interface TodoItem { + content: string; + status: string; + priority: string; +} + +export interface TodoWriteInput { + todos: TodoItem[]; +} + +export interface TodoWriteMetadata extends ToolMetadata { + todos: TodoItem[]; +} + +export type WebSearchLivecrawl = "fallback" | "preferred"; +export type WebSearchType = "auto" | "fast" | "deep"; +export type WebSearchProvider = "exa" | "parallel"; + +export interface WebSearchInput { + query: string; + numResults?: number; + livecrawl?: WebSearchLivecrawl; + type?: WebSearchType; + contextMaxCharacters?: number; +} + +export interface WebSearchMetadata extends ToolMetadata { + provider: WebSearchProvider; +} + +export type RepoCloneStatus = "cached" | "cloned" | "refreshed"; + +export interface RepoCloneInput { + repository: string; + refresh?: boolean; + branch?: string; +} + +export interface RepoCloneMetadata extends ToolMetadata { + repository: string; + host: string; + remote: string; + localPath: string; + status: RepoCloneStatus; + head?: string; + branch?: string; +} + +export interface RepoOverviewInput { + repository?: string; + path?: string; + depth?: number; +} + +export interface RepoOverviewMetadata extends ToolMetadata { + path: string; + repository?: string; + branch?: string; + head?: string; + package_manager?: string; + ecosystems: string[]; + dependency_files: string[]; + entrypoints: string[]; + depth: number; + truncated: boolean; +} + +export interface SkillInput { + name: string; +} + +export interface SkillMetadata extends ToolMetadata { + name: string; + dir: string; +} + +export interface ApplyPatchInput { + patchText: string; +} + +export type ApplyPatchFileType = "add" | "update" | "delete" | "move"; + +export interface ApplyPatchFile { + filePath: string; + relativePath: string; + type: ApplyPatchFileType; + patch: string; + additions: number; + deletions: number; + movePath?: string; +} + +export interface ApplyPatchMetadata extends ToolMetadata { + diff: string; + files: ApplyPatchFile[]; + diagnostics: unknown; +} + +export type LspOperation = + | "goToDefinition" + | "findReferences" + | "hover" + | "documentSymbol" + | "workspaceSymbol" + | "goToImplementation" + | "prepareCallHierarchy" + | "incomingCalls" + | "outgoingCalls"; + +export interface LspInput { + operation: LspOperation; + filePath: string; + line: number; + character: number; + query?: string; +} + +export interface LspMetadata extends ToolMetadata { + result: unknown[]; +} + +export interface PlanExitInput {} + +export interface PlanExitMetadata extends ToolMetadata {} + +type BuiltInDynamicToolPart = + DynamicToolUIPart & { toolName: ToolName } & ( + | { state: "output-available"; input: Input; output: Output } + | { state: "output-error"; input: Input; errorText: string } + | { + state: Exclude; + input: Input; + } + ); + +export type BashToolPart = BuiltInDynamicToolPart<"bash", BashInput>; + +export function isBashToolPart(part: ToolUIPart | DynamicToolUIPart): part is BashToolPart { + return part.type === "dynamic-tool" && part.toolName === "bash"; +} + +export type EditToolPart = BuiltInDynamicToolPart<"edit", EditInput>; + +export function isEditToolPart(part: ToolUIPart | DynamicToolUIPart): part is EditToolPart { + return part.type === "dynamic-tool" && part.toolName === "edit"; +} + +export type WriteToolPart = BuiltInDynamicToolPart<"write", WriteInput>; + +export function isWriteToolPart(part: ToolUIPart | DynamicToolUIPart): part is WriteToolPart { + return part.type === "dynamic-tool" && part.toolName === "write"; +} + +export type ReadToolPart = BuiltInDynamicToolPart<"read", ReadInput>; + +export function isReadToolPart(part: ToolUIPart | DynamicToolUIPart): part is ReadToolPart { + return part.type === "dynamic-tool" && part.toolName === "read"; +} + +export type GrepToolPart = BuiltInDynamicToolPart<"grep", GrepInput>; + +export function isGrepToolPart(part: ToolUIPart | DynamicToolUIPart): part is GrepToolPart { + return part.type === "dynamic-tool" && part.toolName === "grep"; +} + +export type GlobToolPart = BuiltInDynamicToolPart<"glob", GlobInput>; + +export function isGlobToolPart(part: ToolUIPart | DynamicToolUIPart): part is GlobToolPart { + return part.type === "dynamic-tool" && part.toolName === "glob"; +} + +export type LspToolPart = BuiltInDynamicToolPart<"lsp", LspInput>; + +export function isLspToolPart(part: ToolUIPart | DynamicToolUIPart): part is LspToolPart { + return part.type === "dynamic-tool" && part.toolName === "lsp"; +} + +export type ApplyPatchToolPart = BuiltInDynamicToolPart<"apply_patch", ApplyPatchInput>; + +export function isApplyPatchToolPart(part: ToolUIPart | DynamicToolUIPart): part is ApplyPatchToolPart { + return part.type === "dynamic-tool" && part.toolName === "apply_patch"; +} + +export type SkillToolPart = BuiltInDynamicToolPart<"skill", SkillInput>; + +export function isSkillToolPart(part: ToolUIPart | DynamicToolUIPart): part is SkillToolPart { + return part.type === "dynamic-tool" && part.toolName === "skill"; +} + +export type TodoWriteToolPart = BuiltInDynamicToolPart<"todowrite", TodoWriteInput>; + +export function isTodoWriteToolPart(part: ToolUIPart | DynamicToolUIPart): part is TodoWriteToolPart { + return part.type === "dynamic-tool" && part.toolName === "todowrite"; +} + +export type WebFetchToolPart = BuiltInDynamicToolPart<"webfetch", WebFetchInput>; + +export function isWebFetchToolPart(part: ToolUIPart | DynamicToolUIPart): part is WebFetchToolPart { + return part.type === "dynamic-tool" && part.toolName === "webfetch"; +} + +export type WebSearchToolPart = BuiltInDynamicToolPart<"websearch", WebSearchInput>; + +export function isWebSearchToolPart(part: ToolUIPart | DynamicToolUIPart): part is WebSearchToolPart { + return part.type === "dynamic-tool" && part.toolName === "websearch"; +} + +export type QuestionToolPart = BuiltInDynamicToolPart<"question", QuestionInput>; + +export function isQuestionToolPart(part: ToolUIPart | DynamicToolUIPart): part is QuestionToolPart { + return part.type === "dynamic-tool" && part.toolName === "question"; +} diff --git a/apps/app/src/lib/messages.ts b/apps/app/src/lib/messages.ts new file mode 100644 index 0000000000..316b117b02 --- /dev/null +++ b/apps/app/src/lib/messages.ts @@ -0,0 +1 @@ +export type ThreadStatus = "submitted" | "streaming" | "retrying" | "ready"; diff --git a/apps/app/src/lib/target-provider.ts b/apps/app/src/lib/target-provider.ts new file mode 100644 index 0000000000..9ee598be1e --- /dev/null +++ b/apps/app/src/lib/target-provider.ts @@ -0,0 +1,43 @@ +import * as React from "react"; + +import type { OpenTarget } from "@/react-app/domains/session/artifacts/open-target"; + +export type OpenTargetHandler = (target: OpenTarget, options?: { auto?: boolean }) => void; + +type OpenTargetContextValue = { + openTargets: OpenTarget[]; + onOpenTarget: OpenTargetHandler | undefined; +}; + +type OpenTargetProviderProps = { + children: React.ReactNode; + openTargets?: OpenTarget[] | undefined; + onOpenTarget?: OpenTargetHandler | undefined; +}; + +const EMPTY_OPEN_TARGETS: OpenTarget[] = []; + +const OpenTargetContext = React.createContext({ + openTargets: EMPTY_OPEN_TARGETS, + onOpenTarget: undefined, +}); + +export function OpenTargetProvider({ + children, + openTargets = EMPTY_OPEN_TARGETS, + onOpenTarget, +}: OpenTargetProviderProps) { + const value = React.useMemo( + () => ({ + openTargets, + onOpenTarget, + }), + [openTargets, onOpenTarget], + ); + + return React.createElement(OpenTargetContext.Provider, { value }, children); +} + +export function useOpenTargets() { + return React.useContext(OpenTargetContext); +} diff --git a/apps/app/src/lib/websearch-results.ts b/apps/app/src/lib/websearch-results.ts new file mode 100644 index 0000000000..01494e9c37 --- /dev/null +++ b/apps/app/src/lib/websearch-results.ts @@ -0,0 +1,33 @@ +export interface WebSearchResult { + title: string + url: string + description?: string +} + +const TITLE_PATTERN = /^Title:\s*(.+)$/m +const URL_PATTERN = /^URL:\s*(https?:\/\/\S+)$/m +const HIGHLIGHTS_PATTERN = /(?:^|\n)Highlights:\s*([\s\S]*?)(?:\n\[\.\.\.\]|$)/ + +export function parseWebSearchResults(output: string): WebSearchResult[] { + return output.split(/\n---\n/).flatMap((block) => { + const title = block.match(TITLE_PATTERN)?.[1]?.trim() + const url = block.match(URL_PATTERN)?.[1]?.trim() + const description = block.match(HIGHLIGHTS_PATTERN)?.[1]?.trim() + + if (!url) { + return [] + } + + try { + return [ + { + title: title && title !== "N/A" ? title : new URL(url).hostname, + url, + description, + }, + ] + } catch { + return [] + } + }) +} diff --git a/apps/app/src/react-app/domains/session/artifacts/open-target.ts b/apps/app/src/react-app/domains/session/artifacts/open-target.ts index 6d7af4091d..e5ff4ea3d7 100644 --- a/apps/app/src/react-app/domains/session/artifacts/open-target.ts +++ b/apps/app/src/react-app/domains/session/artifacts/open-target.ts @@ -50,6 +50,7 @@ const WRITE_TOOL_NAMES = new Set([ const FILE_METADATA_KEYS = ["path", "file", "filePath", "filepath"]; const PATCH_FILE_PATTERN = /^\*\*\* (?:Add File|Update File):\s*(.+)$/gmi; const PATCH_MOVE_TO_PATTERN = /^\*\*\* Move to:\s*(.+)$/gmi; +const URI_PATTERN = /^(?:https?|wss?|file):\/\//i; type DeriveOpenTargetsOptions = { includeFileMentions?: boolean; @@ -243,6 +244,18 @@ export function deriveOpenTargets(messages: UIMessage[], options: DeriveOpenTarg continue; } + if (part.type === "source-document") { + addTarget( + targets, + part.filename + ? targetFromFile(part.filename, 95, "attachment source") + : URI_PATTERN.test(part.title) + ? targetFromUrl(part.title, 95, "attachment source") + : targetFromFile(part.title, 95, "attachment source"), + ); + continue; + } + if (part.type !== "dynamic-tool") { continue; } diff --git a/apps/app/src/react-app/domains/session/status/session-activity-store.ts b/apps/app/src/react-app/domains/session/status/session-activity-store.ts index b313cc7c82..9a3f9e6751 100644 --- a/apps/app/src/react-app/domains/session/status/session-activity-store.ts +++ b/apps/app/src/react-app/domains/session/status/session-activity-store.ts @@ -12,6 +12,7 @@ type SessionActivityRecord = { runActive: boolean; assistantOutput: boolean; errorActive: boolean; + errorMessage: string | null; compacting: boolean; waitingPermissionIds: string[]; waitingQuestionIds: string[]; @@ -30,6 +31,7 @@ type SessionActivityStore = { recordsByWorkspaceId: Record>; statusesByWorkspaceId: Record>; getStatus: (workspaceId: string, sessionId: string) => SessionActivityStatus; + getSessionError: (workspaceId: string, sessionId: string) => string | null; seedWorkspaceSessions: (workspaceId: string, sessions: SessionLike[]) => void; seedSessionRun: (workspaceId: string, sessionId: string, status: unknown, assistantOutput: boolean) => void; setRunStatus: (workspaceId: string, sessionId: string, status: unknown) => void; @@ -37,17 +39,40 @@ type SessionActivityStore = { markAssistantOutput: (workspaceId: string, sessionId: string, messageId?: string, options?: { allowUnknownMessageRole?: boolean }) => void; setWaitingRequest: (workspaceId: string, sessionId: string, kind: "permission" | "question", requestId: string, waiting: boolean) => void; replaceWaitingRequests: (workspaceId: string, sessionId: string, kind: "permission" | "question", requestIds: string[]) => void; - setError: (workspaceId: string, sessionId: string) => void; + setError: (workspaceId: string, sessionId: string, message?: string) => void; clearError: (workspaceId: string, sessionId: string) => void; setCompacting: (workspaceId: string, sessionId: string, compacting: boolean) => void; removeSession: (workspaceId: string, sessionId: string) => void; }; +export function sessionErrorMessageFromProperties(properties: unknown): string { + if (!properties || typeof properties !== "object") { + return "Session failed"; + } + + const record = properties as Record; + + if (typeof record.error === "object" && record.error !== null && "message" in record.error) { + const message = record.error.message; + + if (typeof message === "string" && message.trim()) { + return message.trim(); + } + } + + if (typeof record.error === "string" && record.error.trim()) { + return record.error.trim(); + } + + return "Session failed"; +} + const createRecord = (): SessionActivityRecord => ({ status: "idle", runActive: false, assistantOutput: false, errorActive: false, + errorMessage: null, compacting: false, waitingPermissionIds: [], waitingQuestionIds: [], @@ -134,6 +159,22 @@ export const useSessionActivityStore = create((set, get) = getStatus: (workspaceId, sessionId) => ( get().statusesByWorkspaceId[workspaceId]?.[sessionId] ?? "idle" ), + getSessionError: (workspaceId, sessionId) => { + const workspace = workspaceId.trim(); + const session = sessionId.trim(); + + if (!workspace || !session) { + return null; + } + + const record = get().recordsByWorkspaceId[workspace]?.[session]; + + if (!record?.errorActive) { + return null; + } + + return record.errorMessage; + }, seedWorkspaceSessions: (workspaceId, sessions) => { const id = workspaceId.trim(); if (!id) return; @@ -155,6 +196,7 @@ export const useSessionActivityStore = create((set, get) = runActive, assistantOutput: runActive && record.runActive ? record.assistantOutput : false, errorActive: runActive ? false : record.errorActive, + errorMessage: runActive ? null : record.errorMessage, compacting: runActive ? record.compacting : false, waitingPermissionIds: runActive ? record.waitingPermissionIds : [], waitingQuestionIds: runActive ? record.waitingQuestionIds : [], @@ -178,6 +220,7 @@ export const useSessionActivityStore = create((set, get) = runActive, assistantOutput: runActive && assistantOutput, errorActive: runActive ? false : record.errorActive, + errorMessage: runActive ? null : record.errorMessage, compacting: runActive ? record.compacting : false, waitingPermissionIds: runActive ? record.waitingPermissionIds : [], waitingQuestionIds: runActive ? record.waitingQuestionIds : [], @@ -196,6 +239,7 @@ export const useSessionActivityStore = create((set, get) = runActive, assistantOutput: runActive && record.runActive ? record.assistantOutput : false, errorActive: runActive ? false : record.errorActive, + errorMessage: runActive ? null : record.errorMessage, compacting: runActive ? record.compacting : false, waitingPermissionIds: runActive ? record.waitingPermissionIds : [], waitingQuestionIds: runActive ? record.waitingQuestionIds : [], @@ -250,13 +294,14 @@ export const useSessionActivityStore = create((set, get) = [kind === "permission" ? "waitingPermissionIds" : "waitingQuestionIds"]: ids, }))); }, - setError: (workspaceId, sessionId) => { + setError: (workspaceId, sessionId, message) => { const workspace = workspaceId.trim(); const session = sessionId.trim(); if (!workspace || !session) return; set((state) => updateRecord(state, workspace, session, (record) => ({ ...record, errorActive: true, + errorMessage: message ? message : "Session failed", runActive: false, assistantOutput: false, compacting: false, @@ -269,6 +314,7 @@ export const useSessionActivityStore = create((set, get) = set((state) => updateRecord(state, workspace, session, (record) => ({ ...record, errorActive: false, + errorMessage: null, }))); }, setCompacting: (workspaceId, sessionId, compacting) => { @@ -279,6 +325,7 @@ export const useSessionActivityStore = create((set, get) = ...record, compacting, errorActive: compacting ? false : record.errorActive, + errorMessage: compacting ? null : record.errorMessage, }))); }, removeSession: (workspaceId, sessionId) => { diff --git a/apps/app/src/react-app/domains/session/surface/scroll-controller.ts b/apps/app/src/react-app/domains/session/surface/scroll-controller.ts index 5c1660ac73..b5c20a7722 100644 --- a/apps/app/src/react-app/domains/session/surface/scroll-controller.ts +++ b/apps/app/src/react-app/domains/session/surface/scroll-controller.ts @@ -1,6 +1,14 @@ import { useCallback, useEffect, useRef, type RefObject, type UIEventHandler } from "react"; -import { getSessionScrollState, useSessionScrollStore } from "./scroll-store"; +import { getSessionScrollState, useSessionScrollStore, type SessionScrollState } from "./scroll-store"; + +function readScrollState(sessionId: string | null): SessionScrollState { + return getSessionScrollState(useSessionScrollStore.getState().sessions, sessionId); +} + +function isStickyBottom(sessionId: string | null) { + return readScrollState(sessionId).mode === "stickyBottom"; +} const EXACT_BOTTOM_GAP_PX = 1; // Widened from 250ms so a single wheel or trackpad flick isn't missed between @@ -68,11 +76,6 @@ export function useSessionScrollController( options: SessionScrollControllerOptions, ) { const selectedSessionId = options.selectedSessionId; - const selectScrollState = useCallback( - (state: ReturnType) => getSessionScrollState(state.sessions, selectedSessionId), - [selectedSessionId], - ); - const scrollState = useSessionScrollStore(selectScrollState); const setStickyBottom = useSessionScrollStore((state) => state.setStickyBottom); const setManualScroll = useSessionScrollStore((state) => state.setManualScroll); const setTopClippedMessageId = useSessionScrollStore((state) => state.setTopClippedMessageId); @@ -85,9 +88,6 @@ export function useSessionScrollController( const lastGestureAtRef = useRef(0); const previousSessionIdRef = useRef(null); - const isAtBottom = scrollState.mode === "stickyBottom"; - const topClippedMessageId = scrollState.topClippedMessageId; - const hasScrollGesture = useCallback( () => Date.now() - lastGestureAtRef.current < SCROLL_GESTURE_WINDOW_MS, [], @@ -96,8 +96,8 @@ export function useSessionScrollController( const updateOverflowAnchor = useCallback(() => { const container = options.containerRef.current; if (!container) return; - container.style.overflowAnchor = isAtBottom ? "none" : "auto"; - }, [isAtBottom, options.containerRef]); + container.style.overflowAnchor = isStickyBottom(selectedSessionId) ? "none" : "auto"; + }, [options.containerRef, selectedSessionId]); const markScrollGesture = useCallback( (target?: EventTarget | null) => { @@ -239,21 +239,22 @@ export function useSessionScrollController( const jumpToStartOfMessage = useCallback( (behavior: ScrollBehavior = "smooth") => { - const messageId = topClippedMessageId; + const messageId = readScrollState(selectedSessionId).topClippedMessageId; const container = options.containerRef.current; if (!messageId || !container) return; const target = messageElementById(container, messageId); if (!target) return; - setManualScroll(selectedSessionId, container.scrollTop, topClippedMessageId); + setManualScroll(selectedSessionId, container.scrollTop, messageId); target.scrollIntoView({ behavior, block: "start" }); }, - [options.containerRef, selectedSessionId, setManualScroll, topClippedMessageId], + [options.containerRef, selectedSessionId, setManualScroll], ); useEffect(() => { updateOverflowAnchor(); + return useSessionScrollStore.subscribe(updateOverflowAnchor); }, [updateOverflowAnchor]); useEffect(() => { @@ -275,7 +276,7 @@ export function useSessionScrollController( // touchpad, or scrollbar in the last SCROLL_GESTURE_WINDOW_MS, treat // that as intent to break out of autoscroll and leave their position // alone until the next handleScroll tick reclassifies the mode. - if (grew && isAtBottom && !hasScrollGesture()) { + if (grew && isStickyBottom(selectedSessionId) && !hasScrollGesture()) { scrollToBottom("auto"); return; } @@ -285,7 +286,7 @@ export function useSessionScrollController( observer.observe(content); return () => observer.disconnect(); - }, [hasScrollGesture, isAtBottom, options.contentRef, refreshTopClippedMessage, scrollToBottom]); + }, [hasScrollGesture, options.contentRef, refreshTopClippedMessage, scrollToBottom, selectedSessionId]); useEffect(() => { if (selectedSessionId === previousSessionIdRef.current) return; @@ -333,8 +334,6 @@ export function useSessionScrollController( }, [clearProgrammaticScrollReset]); return { - isAtBottom, - topClippedMessageId, handleScroll, markScrollGesture, scrollToBottom, diff --git a/apps/app/src/react-app/domains/session/surface/scroll-overlay.tsx b/apps/app/src/react-app/domains/session/surface/scroll-overlay.tsx new file mode 100644 index 0000000000..9e50d1bb0a --- /dev/null +++ b/apps/app/src/react-app/domains/session/surface/scroll-overlay.tsx @@ -0,0 +1,93 @@ +import { memo, useCallback } from "react"; + +import { + selectSessionIsStickyBottom, + selectSessionTopClippedMessageId, + useSessionScrollStore, +} from "./scroll-store"; + +function useSessionScrollOverlayState(sessionId: string) { + const isAtBottom = useSessionScrollStore((state) => selectSessionIsStickyBottom(state.sessions, sessionId)); + const topClippedMessageId = useSessionScrollStore((state) => selectSessionTopClippedMessageId(state.sessions, sessionId)); + + return { isAtBottom, topClippedMessageId }; +} + +type JumpToStartButtonProps = { + onJumpToStartOfMessage: (behavior?: ScrollBehavior) => void; +}; + +const JumpToStartButton = memo(function JumpToStartButton({ + onJumpToStartOfMessage, +}: JumpToStartButtonProps) { + const handleClick = useCallback(() => { + onJumpToStartOfMessage("smooth"); + }, [onJumpToStartOfMessage]); + + return ( + + ); +}); + +type JumpToLatestButtonProps = { + onJumpToLatest: (behavior?: ScrollBehavior) => void; +}; + +const JumpToLatestButton = memo(function JumpToLatestButton({ + onJumpToLatest, +}: JumpToLatestButtonProps) { + const handleClick = useCallback(() => { + onJumpToLatest("smooth"); + }, [onJumpToLatest]); + + return ( + + ); +}); + +type SessionScrollOverlayProps = { + sessionId: string; + isStreaming: boolean; + onJumpToLatest: (behavior?: ScrollBehavior) => void; + onJumpToStartOfMessage: (behavior?: ScrollBehavior) => void; +}; + +export const SessionScrollOverlay = memo(function SessionScrollOverlay({ + sessionId, + isStreaming, + onJumpToLatest, + onJumpToStartOfMessage, +}: SessionScrollOverlayProps) { + const { isAtBottom, topClippedMessageId } = useSessionScrollOverlayState(sessionId); + const showJumpToStart = !isStreaming && Boolean(topClippedMessageId); + const showJumpToLatest = !isAtBottom; + + if (!showJumpToStart && !showJumpToLatest) { + return null; + } + + return ( +
    +
    + {showJumpToStart ? ( + + ) : null} + {showJumpToLatest ? ( + + ) : null} +
    +
    + ); +}); diff --git a/apps/app/src/react-app/domains/session/surface/scroll-store.ts b/apps/app/src/react-app/domains/session/surface/scroll-store.ts index 19c1575cdc..d2656ad48b 100644 --- a/apps/app/src/react-app/domains/session/surface/scroll-store.ts +++ b/apps/app/src/react-app/domains/session/surface/scroll-store.ts @@ -88,6 +88,20 @@ export function getSessionScrollState( return sessions[sessionId] ?? INITIAL_SESSION_SCROLL_STATE; } +export function selectSessionIsStickyBottom( + sessions: SessionScrollStateById, + sessionId: string | null | undefined, +): boolean { + return getSessionScrollState(sessions, sessionId).mode === "stickyBottom"; +} + +export function selectSessionTopClippedMessageId( + sessions: SessionScrollStateById, + sessionId: string | null | undefined, +): string | null { + return getSessionScrollState(sessions, sessionId).topClippedMessageId; +} + function setSessionStickyBottom( sessions: SessionScrollStateById, sessionId: string | null | undefined, diff --git a/apps/app/src/react-app/domains/session/surface/session-surface.tsx b/apps/app/src/react-app/domains/session/surface/session-surface.tsx index 8d2b13244b..35d8131a02 100644 --- a/apps/app/src/react-app/domains/session/surface/session-surface.tsx +++ b/apps/app/src/react-app/domains/session/surface/session-surface.tsx @@ -44,6 +44,7 @@ import { SessionTranscript } from "./message-list"; import { useLocal } from "../../../kernel/local-provider"; import { deriveSessionRenderModel } from "../sync/transition-controller"; import { useSessionScrollController } from "./scroll-controller"; +import { SessionScrollOverlay } from "./scroll-overlay"; import { getSessionActivityStatusLabel, useSessionActivityStore, type SessionActivityStatus } from "../status/session-activity-store"; import { PermissionApprovalPanel } from "../chat/permission-approval-modal"; import { QuestionPanel } from "../modals/question-modal"; @@ -61,6 +62,10 @@ import { getComposerPasteParts, useComposerStateStore, } from "./composer-state-store"; +import { MessageList } from "@/components/chat/message-list"; +import { MessageListProvider, type DispatchAction } from "@/components/chat/message-list-provider"; +import { OpenTargetProvider } from "@/lib/target-provider"; +import type { ThreadStatus } from "@/lib/messages"; const EMPTY_TRANSCRIPT: UIMessage[] = []; const IDLE_STATUS: SessionStatus = { type: "idle" }; @@ -416,7 +421,7 @@ function revokeAttachmentPreview(attachment: { previewUrl?: string | undefined } URL.revokeObjectURL(attachment.previewUrl); } -export function SessionSurface(props: SessionSurfaceProps) { +export function SessionSurface(props: SessionSurfaceProps) { const local = useLocal(); const { config: shellConfig } = useShellConfig(); const showThinking = local.prefs.showThinking; @@ -567,6 +572,21 @@ export function SessionSurface(props: SessionSurfaceProps) { }); const liveStatus = statusState ?? snapshot?.status ?? IDLE_STATUS; const chatStreaming = sending || liveStatus.type === "busy" || liveStatus.type === "retry"; + const status = useMemo((): ThreadStatus => { + if (sending) { + return "submitted"; + } + + if (liveStatus.type === "busy") { + return "streaming"; + } + + if (liveStatus.type === "retry") { + return "retrying"; + } + + return "ready"; + }, [liveStatus, sending]); const renderedMessages = useMemo( () => deriveRenderedSessionMessages({ transcriptState, snapshot }), [snapshot, transcriptState], @@ -772,7 +792,7 @@ export function SessionSurface(props: SessionSurfaceProps) { } catch (nextError) { const parsed = parseSessionError(nextError); setError(parsed); - useSessionActivityStore.getState().setError(props.workspaceId, props.sessionId); + useSessionActivityStore.getState().setError(props.workspaceId, props.sessionId, parsed.message); setComposerDraft(props.sessionId, ""); setAwaitingAssistantBaseline(null); setNoVisibleAssistantOutputBaseline(null); @@ -1032,6 +1052,24 @@ export function SessionSurface(props: SessionSurfaceProps) { contentRef, }); + const handleMessageListDispatchAction = useCallback((action: DispatchAction) => { + if (action.target === "settings" && action.action === "open") { + props.onOpenSettingsSection?.(action.section); + } + }, [props.onOpenSettingsSection]); + + const handleMessageListSetPrompt = useCallback((prompt: string) => { + void typeComposerText(prompt); + }, [typeComposerText]); + + const handleRevertToUserMessage = useCallback((messageId: string) => { + props.onRevertToMessage?.(messageId); + }, [props.onRevertToMessage]); + + const handleForkAtMessage = useCallback((messageId: string) => { + props.onForkAtMessage?.(messageId); + }, [props.onForkAtMessage]); + const sessionScrollTopControlAction = useMemo(() => ({ id: "session.scroll_top", label: "Go to the top of the session", @@ -1163,60 +1201,35 @@ export function SessionSurface(props: SessionSurfaceProps) {
    - ) : renderedMessages.length === 0 && snapshot && snapshot.messages.length === 0 ? ( - error ? ( - - ) : shellConfig.starterCards ? ( -
    -
    -

    Try one of these:

    -
    - - - -
    -
    -
    - ) : null + ) : renderedMessages.length === 0 && snapshot && snapshot.messages.length === 0 && error ? ( + ) : ( <> - + + + + + {/* - ) : null} + /> */} + {/* ) : null} */} )}
    - {!sessionScroll.isAtBottom || (!chatStreaming && sessionScroll.topClippedMessageId) ? ( -
    -
    - {!chatStreaming && sessionScroll.topClippedMessageId ? ( - - ) : null} - {!sessionScroll.isAtBottom ? ( - - ) : null} -
    -
    - ) : null} +
    diff --git a/apps/app/src/react-app/domains/session/sync/parse-tool-parts.ts b/apps/app/src/react-app/domains/session/sync/parse-tool-parts.ts new file mode 100644 index 0000000000..1565382c49 --- /dev/null +++ b/apps/app/src/react-app/domains/session/sync/parse-tool-parts.ts @@ -0,0 +1,78 @@ +import type { DynamicToolUIPart, TextUIPart } from "ai"; +import type { ToolPart } from "@opencode-ai/sdk/v2/client"; + +import { safeStringify } from "@/app/utils"; + +export const STRUCTURED_OUTPUT_TOOL = "StructuredOutput"; + +export function shouldDeferInProgressTool(part: ToolPart) { + if (part.state.status === "completed" || part.state.status === "error") { + return false; + } + + return Object.keys(part.state.input).length === 0; +} + +export function parseStructuredOutputUIPart(part: ToolPart): TextUIPart | null { + if (part.state.status === "error") { + return null; + } + + const text = safeStringify(part.state.input); + + if (text === "{}" && part.state.status !== "completed") { + return null; + } + + return { + type: "text", + text, + state: part.state.status === "completed" ? "done" : "streaming", + providerMetadata: { opencode: { partId: `structured-output-${part.callID}`, toolPartId: part.id } }, + }; +} + +export function parseDynamicToolUIPart(part: ToolPart): DynamicToolUIPart | null { + if (part.tool === STRUCTURED_OUTPUT_TOOL) { + return null; + } + + if (part.state.status === "error") { + return { + type: "dynamic-tool", + toolName: part.tool, + toolCallId: part.callID, + state: "output-error", + input: part.state.input, + errorText: part.state.error, + callProviderMetadata: { opencode: { partId: part.id } }, + }; + } + + if (part.state.status === "completed") { + return { + type: "dynamic-tool", + toolName: part.tool, + toolCallId: part.callID, + state: "output-available", + input: part.state.input, + output: part.state.output, + callProviderMetadata: { opencode: { partId: part.id } }, + }; + } + + // OpenCode emits pending/running tool parts with `{}` input before args + // (e.g. filePath) are filled in. Skip UI until the next part.updated. + if (shouldDeferInProgressTool(part)) { + return null; + } + + return { + type: "dynamic-tool", + toolName: part.tool, + toolCallId: part.callID, + state: "input-streaming", + input: part.state.input, + callProviderMetadata: { opencode: { partId: part.id } }, + }; +} diff --git a/apps/app/src/react-app/domains/session/sync/session-sync.ts b/apps/app/src/react-app/domains/session/sync/session-sync.ts index 820c6ece84..945b9132ab 100644 --- a/apps/app/src/react-app/domains/session/sync/session-sync.ts +++ b/apps/app/src/react-app/domains/session/sync/session-sync.ts @@ -1,14 +1,21 @@ import type { UIMessage } from "ai"; -import type { Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"; +import type { FilePart, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"; import { getReactQueryClient } from "../../../infra/query-client"; import { createClient } from "@/app/lib/opencode"; -import { normalizeEvent, safeStringify } from "@/app/utils"; +import { normalizeEvent } from "@/app/utils"; import { SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX, type OpencodeEvent, type PendingPermission, type PendingQuestion } from "@/app/types"; import { createSessionErrorUIMessage, describeOpencodeSessionError, snapshotToUIMessages } from "./usechat-adapter"; +import { + parseDynamicToolUIPart, + parseStructuredOutputUIPart, + STRUCTURED_OUTPUT_TOOL, +} from "./parse-tool-parts"; import type { OpenworkSessionSnapshot } from "@/app/lib/openwork-server"; import { reconcileTranscriptMessages } from "./transcript-reconcile"; -import { useSessionActivityStore } from "../status/session-activity-store"; +import { + useSessionActivityStore, +} from "../status/session-activity-store"; type SyncOptions = { workspaceId: string; @@ -281,10 +288,6 @@ export function seedQuestionState( }); } -type FilePart = Extract; - -const STRUCTURED_OUTPUT_TOOL = "StructuredOutput"; - function fileProviderMetadata(part: FilePart) { if (part.source) { return { opencode: { partId: part.id, source: part.source } }; @@ -355,48 +358,9 @@ function toUIPart(part: Part): UIMessage["parts"][number] | null { } if (part.type === "tool") { if (part.tool === STRUCTURED_OUTPUT_TOOL) { - if (part.state.status === "error") return null; - const text = safeStringify(part.state.input); - if (text === "{}" && part.state.status !== "completed") return null; - let state: "done" | "streaming" = "streaming"; - if (part.state.status === "completed") state = "done"; - return { - type: "text", - text, - state, - providerMetadata: { opencode: { partId: `structured-output-${part.callID}`, toolPartId: part.id } }, - }; - } - if (part.state.status === "error") { - return { - type: "dynamic-tool", - toolName: part.tool, - toolCallId: part.callID, - state: "output-error", - input: part.state.input, - errorText: part.state.error, - callProviderMetadata: { opencode: { partId: part.id } }, - }; + return parseStructuredOutputUIPart(part); } - if (part.state.status === "completed") { - return { - type: "dynamic-tool", - toolName: part.tool, - toolCallId: part.callID, - state: "output-available", - input: part.state.input, - output: part.state.output, - callProviderMetadata: { opencode: { partId: part.id } }, - }; - } - return { - type: "dynamic-tool", - toolName: part.tool, - toolCallId: part.callID, - state: "input-available", - input: part.state.input, - callProviderMetadata: { opencode: { partId: part.id } }, - }; + return parseDynamicToolUIPart(part); } if (part.type === "agent") { return { @@ -598,9 +562,9 @@ function applyEvent(entry: SyncEntry, workspaceId: string, event: OpencodeEvent) if (event.type === "session.error") { const sessionId = sessionIdFromProperties(event.properties); if (sessionId) { - useSessionActivityStore.getState().setError(workspaceId, sessionId); + const errorText = describeOpencodeSessionError(sessionErrorFromProperties(event.properties)); + useSessionActivityStore.getState().setError(workspaceId, sessionId, errorText); if (isTrackedSession(entry, sessionId)) { - const errorText = describeOpencodeSessionError(sessionErrorFromProperties(event.properties)); queryClient.setQueryData(transcriptKey(workspaceId, sessionId), (current = []) => { // Key the error to the latest assistant turn so it lands beside the // turn that failed and a later turn's error becomes its own message diff --git a/apps/app/src/react-app/domains/session/sync/usechat-adapter.ts b/apps/app/src/react-app/domains/session/sync/usechat-adapter.ts index f0cd9a7602..6c279791fa 100644 --- a/apps/app/src/react-app/domains/session/sync/usechat-adapter.ts +++ b/apps/app/src/react-app/domains/session/sync/usechat-adapter.ts @@ -1,12 +1,18 @@ /** @jsxImportSource react */ -import type { UIMessage, UIMessageChunk, ChatTransport, DynamicToolUIPart } from "ai"; -import type { Part } from "@opencode-ai/sdk/v2/client"; +import type { UIMessage, UIMessageChunk, ChatTransport } from "ai"; +import type { FilePart, Part, ReasoningPart, TextPart, ToolPart } from "@opencode-ai/sdk/v2/client"; import { abortSessionSafe } from "../../../../app/lib/opencode-session"; -import type { OpenworkSessionMessage, OpenworkSessionSnapshot } from "../../../../app/lib/openwork-server"; +import type { OpenworkSessionSnapshot } from "../../../../app/lib/openwork-server"; import { normalizeEvent, safeStringify } from "../../../../app/utils"; import { SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX, type OpencodeEvent } from "../../../../app/types"; import { createClient } from "../../../../app/lib/opencode"; +import { + parseDynamicToolUIPart, + parseStructuredOutputUIPart, + shouldDeferInProgressTool, + STRUCTURED_OUTPUT_TOOL, +} from "./parse-tool-parts"; type TransportOptions = { baseUrl: string; @@ -42,12 +48,6 @@ type InternalPartState = { streamFinished: boolean; }; -type ToolPart = Extract; -type TextPart = Extract; -type FilePart = Extract; - -const STRUCTURED_OUTPUT_TOOL = "StructuredOutput"; - function recordValue(value: unknown, key: string) { if (!value || typeof value !== "object") return undefined; return (value as Record)[key]; @@ -145,7 +145,7 @@ function getTextPartValue(part: Part) { return ""; } -function getTextPartDelta(part: TextPart, delta: string, values: Map) { +function getTextPartDelta(part: TextPart | ReasoningPart, delta: string, values: Map) { const nextText = part.text; if (delta) { const previous = values.get(part.id); @@ -202,42 +202,20 @@ function mapFileParts(part: FilePart): UIMessage["parts"] { return [mapFilePart(part)]; } -function mapToolPart(part: ToolPart): DynamicToolUIPart { - const toolName = part.tool; - const input = part.state.input; - - if (part.state.status === "error") { - return { - type: "dynamic-tool", - toolName, - toolCallId: part.callID, - state: "output-error", - input, - callProviderMetadata: { opencode: { partId: part.id } }, - errorText: part.state.error, - }; +function mapSnapshotToolParts(part: ToolPart): UIMessage["parts"] { + if (part.tool === STRUCTURED_OUTPUT_TOOL) { + const mapped = parseStructuredOutputUIPart(part); + return mapped ? [mapped] : []; } - if (part.state.status === "completed") { - return { - type: "dynamic-tool", - toolName, - toolCallId: part.callID, - state: "output-available", - input, - callProviderMetadata: { opencode: { partId: part.id } }, - output: part.state.output, - }; + const mapped = parseDynamicToolUIPart(part); + if (!mapped) return []; + + if (part.state.status === "completed" && part.state.attachments) { + return [mapped, ...part.state.attachments.flatMap(mapFileParts)]; } - return { - type: "dynamic-tool", - toolName, - toolCallId: part.callID, - state: "input-available", - input, - callProviderMetadata: { opencode: { partId: part.id } }, - }; + return [mapped]; } export function snapshotToUIMessages(snapshot: OpenworkSessionSnapshot): UIMessage[] { @@ -269,24 +247,7 @@ export function snapshotToUIMessages(snapshot: OpenworkSessionSnapshot): UIMessa return mapFileParts(part); } if (part.type === "tool") { - if (part.tool === STRUCTURED_OUTPUT_TOOL) { - if (part.state.status === "error") return []; - const text = safeStringify(part.state.input); - if (text === "{}" && part.state.status !== "completed") return []; - const partId = `structured-output-${part.callID}`; - let state: "done" | "streaming" = "streaming"; - if (part.state.status === "completed") state = "done"; - return [{ - type: "text", - text, - state, - providerMetadata: { opencode: { partId, toolPartId: part.id } }, - }]; - } - if (part.state.status === "completed" && part.state.attachments) { - return [mapToolPart(part), ...part.state.attachments.flatMap(mapFileParts)]; - } - return [mapToolPart(part)]; + return mapSnapshotToolParts(part); } if (part.type === "agent") { return [{ @@ -415,7 +376,7 @@ function enqueueTextDelta( function flushPendingDeltas( controller: ReadableStreamDefaultController, state: InternalPartState, - part: TextPart, + part: TextPart | ReasoningPart, ) { const pending = state.pendingDeltas.get(part.id); if (!pending) return; @@ -452,7 +413,7 @@ function handleToolPart( }; const inputText = safeStringify(part.state.input); - if (!toolState.inputSent || inputText !== toolState.inputText) { + if (!shouldDeferInProgressTool(part) && (!toolState.inputSent || inputText !== toolState.inputText)) { controller.enqueue({ type: "tool-input-available", toolCallId: part.callID, diff --git a/apps/app/tests/session-sync-tool-parts.test.ts b/apps/app/tests/session-sync-tool-parts.test.ts new file mode 100644 index 0000000000..fd34d9dae5 --- /dev/null +++ b/apps/app/tests/session-sync-tool-parts.test.ts @@ -0,0 +1,165 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { Part } from "@opencode-ai/sdk/v2/client"; +import type { UIMessage } from "ai"; + +import { getReactQueryClient } from "../src/react-app/infra/query-client"; +import { + __applySessionSyncEventForTest, + __createWorkspaceSessionSyncForTest, + trackWorkspaceSessionSync, + transcriptKey, +} from "../src/react-app/domains/session/sync/session-sync"; +import { + parseDynamicToolUIPart, + parseStructuredOutputUIPart, + shouldDeferInProgressTool, +} from "../src/react-app/domains/session/sync/parse-tool-parts"; + +afterEach(() => { + getReactQueryClient().clear(); +}); + +function writeToolPart( + status: "pending" | "running" | "completed" | "error", + input: Record, + overrides: Partial> = {}, +): Extract { + const base = { + id: "part-write", + sessionID: "session-a", + messageID: "msg-a", + type: "tool" as const, + callID: "call-write", + tool: "write", + }; + + if (status === "completed") { + return { + ...base, + ...overrides, + state: { + status: "completed", + input, + output: "ok", + title: "Write", + metadata: {}, + time: { start: 1, end: 2 }, + }, + }; + } + + if (status === "error") { + return { + ...base, + ...overrides, + state: { + status: "error", + input, + error: "failed", + time: { start: 1, end: 2 }, + }, + }; + } + + if (status === "running") { + return { + ...base, + ...overrides, + state: { + status: "running", + input, + time: { start: 1 }, + }, + }; + } + + return { + ...base, + ...overrides, + state: { + status: "pending", + input, + raw: "", + }, + }; +} + +describe("tool part mapper", () => { + test("defers in-progress tools with empty input", () => { + expect(shouldDeferInProgressTool(writeToolPart("pending", {}))).toBe(true); + expect(shouldDeferInProgressTool(writeToolPart("running", {}))).toBe(true); + expect(parseDynamicToolUIPart(writeToolPart("pending", {}))).toBeNull(); + }); + + test("maps in-progress tools with partial input as input-streaming", () => { + const part = writeToolPart("running", { content: "hello" }); + expect(parseDynamicToolUIPart(part)).toMatchObject({ + type: "dynamic-tool", + toolName: "write", + state: "input-streaming", + input: { content: "hello" }, + }); + }); + + test("maps completed tools", () => { + const part = writeToolPart("completed", { content: "hello", filePath: "src/a.ts" }); + expect(parseDynamicToolUIPart(part)).toMatchObject({ + state: "output-available", + input: { content: "hello", filePath: "src/a.ts" }, + output: "ok", + }); + }); + + test("skips empty structured output while streaming", () => { + const part = writeToolPart("running", {}, { tool: "StructuredOutput" }); + expect(parseStructuredOutputUIPart(part)).toBeNull(); + expect(Object.keys(part.state.input).length).toBe(0); + }); + + test("keeps completed structured output even when input is {}", () => { + const part = writeToolPart("completed", {}, { tool: "StructuredOutput" }); + expect(parseStructuredOutputUIPart(part)).toMatchObject({ + type: "text", + text: "{}", + state: "done", + }); + }); + + test("session sync defers empty in-progress write tools until input arrives", () => { + const syncInput = { workspaceId: "workspace-a", baseUrl: "http://127.0.0.1:1234", openworkToken: "token" }; + const cleanup = __createWorkspaceSessionSyncForTest(syncInput); + const release = trackWorkspaceSessionSync(syncInput, "session-a"); + + try { + __applySessionSyncEventForTest(syncInput, { + type: "message.updated", + properties: { info: { id: "msg-a", role: "assistant", sessionID: "session-a" } }, + } as any); + __applySessionSyncEventForTest(syncInput, { + type: "message.part.updated", + properties: { part: writeToolPart("pending", {}) }, + } as any); + + let transcript = getReactQueryClient().getQueryData(transcriptKey("workspace-a", "session-a")); + expect(transcript?.[0]?.parts ?? []).toEqual([]); + + __applySessionSyncEventForTest(syncInput, { + type: "message.part.updated", + properties: { + part: writeToolPart("running", { content: "hello", filePath: "src/main.ts" }), + }, + } as any); + + transcript = getReactQueryClient().getQueryData(transcriptKey("workspace-a", "session-a")); + expect(transcript?.[0]?.parts[0]).toMatchObject({ + type: "dynamic-tool", + toolName: "write", + state: "input-streaming", + input: { content: "hello", filePath: "src/main.ts" }, + }); + } finally { + release(); + cleanup(); + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39f8d2f0ae..478d1ab495 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,6 +78,9 @@ importers: '@radix-ui/colors': specifier: ^3.0.0 version: 3.0.0 + '@radix-ui/react-use-controllable-state': + specifier: ^1.2.2 + version: 1.2.2(@types/react@19.2.14)(react@19.2.4) '@shikijs/transformers': specifier: ^4.0.2 version: 4.0.2 @@ -102,6 +105,9 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + dompurify: + specifier: ^3.4.5 + version: 3.4.5 emojilib: specifier: ^4.0.3 version: 4.0.3 @@ -135,18 +141,30 @@ importers: react-dom: specifier: 'catalog:' version: 19.2.4(react@19.2.4) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@19.2.14)(react@19.2.4) react-resizable-panels: specifier: ^4.11.0 version: 4.11.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-router-dom: specifier: ^7.14.1 version: 7.14.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + remark-breaks: + specifier: ^4.0.0 + version: 4.0.0 + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 shadcn: specifier: ^4.6.0 version: 4.6.0(@types/node@25.6.0)(typescript@5.9.3) shiki: specifier: ^4.0.2 version: 4.0.2 + streamdown: + specifier: ^2.5.0 + version: 2.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tailwind-merge: specifier: ^3.5.0 version: 3.5.0 @@ -819,6 +837,9 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@authenio/xml-encryption@2.0.2': resolution: {integrity: sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==} engines: {node: '>=12'} @@ -1310,6 +1331,12 @@ packages: '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@codemirror/autocomplete@6.20.0': resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==} @@ -2082,6 +2109,12 @@ packages: '@iarna/toml@2.2.5': resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.3': + resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -2520,6 +2553,9 @@ packages: '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + '@mermaid-js/parser@1.1.1': + resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -4105,15 +4141,114 @@ packages: '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -4185,6 +4320,12 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -4203,6 +4344,9 @@ packages: '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vercel/oidc@3.1.0': resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} engines: {node: '>= 20'} @@ -4434,6 +4578,9 @@ packages: solid-js: optional: true + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -4728,6 +4875,12 @@ packages: character-entities-legacy@3.0.0: resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -4860,6 +5013,14 @@ packages: resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} engines: {node: '>= 6'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + compare-version@0.1.2: resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} engines: {node: '>=0.10.0'} @@ -4921,6 +5082,12 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + cosmiconfig@9.0.1: resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} engines: {node: '>=14'} @@ -4961,10 +5128,169 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.33.4: + resolution: {integrity: sha512-HIN5Pmd9MrX9BkV7tDwnOcEJCSFvCpc8X97h3f508J6I5FsqAY65wKOCvgH2CuP42CaahWaz4tuh32SOOIH7ww==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + debounce-fn@6.0.0: resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==} engines: {node: '>=18'} @@ -4982,6 +5308,9 @@ packages: supports-color: optional: true + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -5032,6 +5361,9 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -5096,6 +5428,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.4.5: + resolution: {integrity: sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -5327,6 +5662,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.47.0: + resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==} + es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} @@ -5366,11 +5704,18 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -5429,6 +5774,9 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + extract-zip@2.0.1: resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} engines: {node: '>= 10.17.0'} @@ -5748,6 +6096,9 @@ packages: resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -5770,12 +6121,33 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + headers-polyfill@5.0.1: resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} @@ -5822,6 +6194,9 @@ packages: resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} engines: {node: '>=14'} + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -5899,6 +6274,9 @@ packages: import-in-the-middle@2.0.6: resolution: {integrity: sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -5920,6 +6298,16 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} @@ -5928,6 +6316,12 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -5943,6 +6337,9 @@ packages: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -5963,6 +6360,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-in-ssh@1.0.0: resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} engines: {node: '>=20'} @@ -6142,9 +6542,16 @@ packages: jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -6157,6 +6564,12 @@ packages: resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==} engines: {node: '>=20.0.0'} + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} @@ -6271,6 +6684,9 @@ packages: resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} engines: {node: '>=6'} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} @@ -6314,6 +6730,9 @@ packages: long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -6360,6 +6779,9 @@ packages: resolution: {integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked-emoji@2.0.3: resolution: {integrity: sha512-fChW/AfUqCHgoEC1nFDgiw3OR/qsi71/QXH/HTo05yd6B5+T+VHh1SqCpn/HpeGLDxkA+MK4+hr4eULB2/A8Jw==} peerDependencies: @@ -6376,6 +6798,11 @@ packages: engines: {node: '>= 18'} hasBin: true + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + marked@17.0.1: resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} engines: {node: '>= 20'} @@ -6389,9 +6816,54 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-newline-to-break@2.0.0: + resolution: {integrity: sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + mdast-util-to-hast@13.2.1: resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} @@ -6410,21 +6882,93 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + mermaid@11.15.0: + resolution: {integrity: sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + micromark-util-character@2.1.1: resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + micromark-util-encode@2.0.1: resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + micromark-util-sanitize-uri@2.0.1: resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + micromark-util-symbol@2.0.1: resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} micromark-util-types@2.0.2: resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -6893,6 +7437,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -6909,6 +7456,9 @@ packages: parse-bmfont-xml@1.1.6: resolution: {integrity: sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -6934,6 +7484,9 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} @@ -7058,6 +7611,12 @@ packages: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + postal-mime@2.7.4: resolution: {integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==} @@ -7259,6 +7818,12 @@ packages: peerDependencies: react: '>=16.13.1' + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -7374,6 +7939,33 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + rehype-harden@1.1.8: + resolution: {integrity: sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw==} + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + + remark-breaks@4.0.0: + resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + remend@1.3.0: + resolution: {integrity: sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -7459,6 +8051,9 @@ packages: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rollup@4.55.1: resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -7467,6 +8062,9 @@ packages: rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -7478,6 +8076,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + s-js@0.4.9: resolution: {integrity: sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ==} @@ -7718,6 +8319,12 @@ packages: stream-browserify@3.0.0: resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} + streamdown@2.5.0: + resolution: {integrity: sha512-/tTnURfIOxZK/pqJAxsfCvETG/XCJHoWnk3jq9xLcuz6CSpnjjuxSRBTTL4PKGhxiZQf0lqPxGhImdpwcZ2XwA==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} @@ -7799,6 +8406,12 @@ packages: style-mod@4.1.3: resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + styled-jsx@5.1.1: resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} engines: {node: '>= 12.0.0'} @@ -7825,6 +8438,9 @@ packages: babel-plugin-macros: optional: true + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -7975,9 +8591,16 @@ packages: trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + truncate-utf8-bytes@1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + ts-dedent@2.2.0: + resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} + engines: {node: '>=6.10'} + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -8070,6 +8693,9 @@ packages: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + unique-filename@2.0.1: resolution: {integrity: sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -8180,6 +8806,9 @@ packages: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -8272,6 +8901,9 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -8522,6 +9154,11 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.1.2 + '@authenio/xml-encryption@2.0.2': dependencies: '@xmldom/xmldom': 0.8.13 @@ -9441,6 +10078,10 @@ snapshots: '@better-fetch/fetch@1.1.21': {} + '@braintree/sanitize-url@7.1.2': {} + + '@chevrotain/types@11.1.2': {} + '@codemirror/autocomplete@6.20.0': dependencies: '@codemirror/language': 6.12.1 @@ -10053,6 +10694,14 @@ snapshots: '@iarna/toml@2.2.5': {} + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.3': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + '@img/colour@1.1.0': optional: true @@ -10600,6 +11249,10 @@ snapshots: '@marijn/find-cluster-break@1.0.2': {} + '@mermaid-js/parser@1.1.1': + dependencies: + '@chevrotain/types': 11.1.2 + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.11(hono@4.12.12) @@ -12129,19 +12782,142 @@ snapshots: dependencies: '@types/node': 25.6.0 - '@types/debug@4.1.13': - dependencies: - '@types/ms': 2.1.0 - - '@types/estree@1.0.8': {} + '@types/d3-array@3.2.2': {} - '@types/fs-extra@9.0.13': + '@types/d3-axis@3.0.6': dependencies: - '@types/node': 25.6.0 + '@types/d3-selection': 3.0.11 - '@types/hast@3.0.4': + '@types/d3-brush@3.0.6': dependencies: - '@types/unist': 3.0.3 + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.8 + + '@types/estree@1.0.8': {} + + '@types/fs-extra@9.0.13': + dependencies: + '@types/node': 25.6.0 + + '@types/geojson@7946.0.16': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 '@types/http-cache-semantics@4.2.0': {} @@ -12216,6 +12992,11 @@ snapshots: '@types/statuses@2.0.6': {} + '@types/trusted-types@2.0.7': + optional: true + + '@types/unist@2.0.11': {} + '@types/unist@3.0.3': {} '@types/validate-npm-package-name@4.0.2': {} @@ -12234,6 +13015,11 @@ snapshots: '@ungap/structured-clone@1.3.1': {} + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + '@vercel/oidc@3.1.0': {} '@vitejs/plugin-react@5.2.0(vite@6.4.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': @@ -12529,6 +13315,8 @@ snapshots: optionalDependencies: solid-js: 1.9.9 + bail@2.0.2: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -12826,6 +13614,10 @@ snapshots: character-entities-legacy@3.0.0: {} + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -12938,6 +13730,10 @@ snapshots: commander@5.1.0: {} + commander@7.2.0: {} + + commander@8.3.0: {} + compare-version@0.1.2: {} compress-commons@4.1.2: @@ -12994,6 +13790,14 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + cosmiconfig@9.0.1(typescript@5.9.3): dependencies: env-paths: 2.2.1 @@ -13032,8 +13836,194 @@ snapshots: csstype@3.2.3: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.4): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.33.4 + + cytoscape-fcose@2.2.0(cytoscape@3.33.4): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.33.4 + + cytoscape@3.33.4: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + data-uri-to-buffer@4.0.1: {} + dayjs@1.11.20: {} + debounce-fn@6.0.0: dependencies: mimic-function: 5.0.1 @@ -13044,6 +14034,10 @@ snapshots: dependencies: ms: 2.1.3 + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -13085,6 +14079,10 @@ snapshots: defu@6.1.4: {} + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + delayed-stream@1.0.0: {} delegates@1.0.0: {} @@ -13156,6 +14154,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.4.5: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -13353,6 +14355,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + es-toolkit@1.47.0: {} + es6-error@4.1.1: optional: true @@ -13482,8 +14486,12 @@ snapshots: escape-string-regexp@4.0.0: optional: true + escape-string-regexp@5.0.0: {} + esprima@4.0.1: {} + estree-util-is-identifier-name@3.0.0: {} + etag@1.8.1: {} event-target-shim@5.0.1: {} @@ -13575,6 +14583,8 @@ snapshots: transitivePeerDependencies: - supports-color + extend@3.0.2: {} + extract-zip@2.0.1: dependencies: debug: 4.4.3 @@ -13945,6 +14955,8 @@ snapshots: graphql@16.13.2: {} + hachure-fill@0.5.2: {} + has-flag@4.0.0: {} has-property-descriptors@1.0.2: @@ -13964,6 +14976,43 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.1.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.1 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.3.1 + unist-util-position: 5.0.0 + hast-util-to-html@9.0.5: dependencies: '@types/hast': 3.0.4 @@ -13978,10 +15027,48 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.8 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + headers-polyfill@5.0.1: dependencies: '@types/set-cookie-parser': 2.4.10 @@ -14023,6 +15110,8 @@ snapshots: htmlparser2: 8.0.2 selderee: 0.11.0 + html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} htmlparser2@8.0.2: @@ -14120,6 +15209,8 @@ snapshots: cjs-module-lexer: 2.2.0 module-details-from-path: 1.0.4 + import-meta-resolve@4.2.0: {} + imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -14135,10 +15226,23 @@ snapshots: ini@1.3.8: {} + inline-style-parser@0.2.7: {} + + internmap@1.0.1: {} + + internmap@2.0.3: {} + ip-address@10.1.0: {} ipaddr.js@1.9.1: {} + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + is-arrayish@0.2.1: {} is-binary-path@2.1.0: @@ -14153,6 +15257,8 @@ snapshots: dependencies: hasown: 2.0.2 + is-decimal@2.0.1: {} + is-docker@3.0.0: {} is-electron@2.2.2: {} @@ -14165,6 +15271,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + is-in-ssh@1.0.0: {} is-inside-container@1.0.0: @@ -14319,16 +15427,26 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 + katex@0.16.47: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 + khroma@2.1.0: {} + kleur@3.0.3: {} kleur@4.1.5: {} kysely@0.28.17: {} + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + lazy-val@1.0.5: {} lazystream@1.0.1: @@ -14409,6 +15527,8 @@ snapshots: p-locate: 3.0.0 path-exists: 3.0.0 + lodash-es@4.18.1: {} + lodash.camelcase@4.3.0: {} lodash.defaults@4.2.0: {} @@ -14444,6 +15564,8 @@ snapshots: long@5.3.2: {} + longest-streak@3.1.0: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -14502,6 +15624,8 @@ snapshots: - bluebird - supports-color + markdown-table@3.0.4: {} + marked-emoji@2.0.3(marked@17.0.1): dependencies: marked: 17.0.1 @@ -14513,6 +15637,8 @@ snapshots: marked@15.0.12: {} + marked@16.4.2: {} + marked@17.0.1: {} matcher@3.0.0: @@ -14522,6 +15648,136 @@ snapshots: math-intrinsics@1.1.0: {} + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-newline-to-break@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-find-and-replace: 3.0.2 + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + mdast-util-to-hast@13.2.1: dependencies: '@types/hast': 3.0.4 @@ -14534,6 +15790,22 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdn-data@2.27.1: {} media-typer@1.1.0: {} @@ -14544,23 +15816,221 @@ snapshots: merge2@1.4.1: {} + mermaid@11.15.0: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.3 + '@mermaid-js/parser': 1.1.1 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.33.4 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.4) + cytoscape-fcose: 2.2.0(cytoscape@3.33.4) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.20 + dompurify: 3.4.5 + es-toolkit: 1.47.0 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.2.0 + uuid: 11.1.0 + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + micromark-util-character@2.1.1: dependencies: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + micromark-util-encode@2.0.1: {} + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + micromark-util-sanitize-uri@2.0.1: dependencies: micromark-util-character: 2.1.1 micromark-util-encode: 2.0.1 micromark-util-symbol: 2.0.1 + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + micromark-util-symbol@2.0.1: {} micromark-util-types@2.0.2: {} + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -15036,6 +16506,8 @@ snapshots: package-json-from-dist@1.0.1: {} + package-manager-detector@1.6.0: {} + pako@1.0.11: {} parent-module@1.0.1: @@ -15051,6 +16523,16 @@ snapshots: xml-parse-from-string: 1.0.1 xml2js: 0.5.0 + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + parse-json@5.2.0: dependencies: '@babel/code-frame': 7.29.0 @@ -15075,6 +16557,8 @@ snapshots: path-browserify@1.0.1: {} + path-data-parser@0.1.0: {} + path-exists@3.0.0: {} path-expression-matcher@1.5.0: {} @@ -15176,6 +16660,13 @@ snapshots: pngjs@7.0.0: {} + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + postal-mime@2.7.4: {} postcss-import@15.1.0(postcss@8.4.38): @@ -15392,6 +16883,24 @@ snapshots: '@babel/runtime': 7.29.2 react: 19.2.4 + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4): + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/react': 19.2.14 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 19.2.4 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + react-refresh@0.18.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): @@ -15514,6 +17023,63 @@ snapshots: dependencies: regex-utilities: 2.3.0 + rehype-harden@1.1.8: + dependencies: + unist-util-visit: 5.1.0 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-sanitize: 5.0.2 + + remark-breaks@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-newline-to-break: 2.0.0 + unified: 11.0.5 + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + remend@1.3.0: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -15590,6 +17156,8 @@ snapshots: sprintf-js: 1.1.3 optional: true + robust-predicates@3.0.3: {} + rollup@4.55.1: dependencies: '@types/estree': 1.0.8 @@ -15623,6 +17191,13 @@ snapshots: rou3@0.7.12: {} + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + router@2.2.0: dependencies: debug: 4.4.3 @@ -15639,6 +17214,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rw@1.3.3: {} + s-js@0.4.9: {} safe-buffer@5.1.2: {} @@ -15983,6 +17560,29 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + streamdown@2.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + clsx: 2.1.1 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + marked: 17.0.1 + mermaid: 11.15.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + rehype-harden: 1.1.8 + rehype-raw: 7.0.0 + rehype-sanitize: 6.0.0 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + remend: 1.3.0 + tailwind-merge: 3.5.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 + transitivePeerDependencies: + - supports-color + streamsearch@1.1.0: {} strict-event-emitter@0.5.1: {} @@ -16059,6 +17659,14 @@ snapshots: style-mod@4.1.3: {} + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + styled-jsx@5.1.1(react@18.2.0): dependencies: client-only: 0.0.1 @@ -16069,6 +17677,8 @@ snapshots: client-only: 0.0.1 react: 19.2.4 + stylis@4.4.0: {} + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -16246,10 +17856,14 @@ snapshots: trim-lines@3.0.1: {} + trough@2.2.0: {} + truncate-utf8-bytes@1.0.2: dependencies: utf8-byte-length: 1.0.5 + ts-dedent@2.2.0: {} + ts-interface-checker@0.1.13: {} ts-morph@26.0.0: @@ -16348,6 +17962,16 @@ snapshots: unicorn-magic@0.3.0: {} + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + unique-filename@2.0.1: dependencies: unique-slug: 3.0.0 @@ -16447,6 +18071,11 @@ snapshots: extsprintf: 1.4.1 optional: true + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -16495,6 +18124,8 @@ snapshots: dependencies: defaults: 1.0.4 + web-namespaces@2.0.1: {} + web-streams-polyfill@3.3.3: {} web-tree-sitter@0.25.10: {} From 411f5843829e9a599ae1b12e31b9fe22d1a94d22 Mon Sep 17 00:00:00 2001 From: Source Open Date: Fri, 29 May 2026 08:42:14 -0700 Subject: [PATCH 2/2] feat(desktop): add steer/queue follow-up messages while agent is busy (#2004) * feat(desktop): add steer/queue follow-up messages while agent is busy Replace the single Stop button with Steer, Queue, and an outline Stop when the agent is running. Steer sends immediately (opencode injects it mid-turn); Queue holds messages client-side and auto-sends them merged into one message once the session goes idle. Enter while busy shakes the buttons to prompt an explicit choice, and Escape arms a "hit Escape again to stop" confirmation. Queued messages show in a panel above the composer (mirroring the question panel) where each can be removed; the panel hides when empty. * fix(desktop): keep queued-message labels index-aligned with drafts The panel labels filtered out empty (attachment-only) drafts, so the remove action could target the wrong queued draft. Map one label per draft instead, falling back to an attachment count when there's no text. Also drop an unused i18n key. --- apps/app/src/app/index.css | 25 +++ apps/app/src/i18n/locales/en.ts | 7 + .../session/modals/queued-messages-panel.tsx | 56 +++++++ .../session/surface/composer/composer.tsx | 147 +++++++++++++++--- .../session/surface/session-surface.tsx | 141 ++++++++++++++--- 5 files changed, 340 insertions(+), 36 deletions(-) create mode 100644 apps/app/src/react-app/domains/session/modals/queued-messages-panel.tsx diff --git a/apps/app/src/app/index.css b/apps/app/src/app/index.css index a2a40a2557..795b1b48b1 100644 --- a/apps/app/src/app/index.css +++ b/apps/app/src/app/index.css @@ -336,6 +336,31 @@ select:disabled { animation: soft-pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; } +/* Quick horizontal shake used to nudge the user toward an action (e.g. when + Enter is pressed while a follow-up message needs Steer/Queue selection). */ +@keyframes ow-shake { + 0%, + 100% { + transform: translateX(0); + } + 20% { + transform: translateX(-5px); + } + 40% { + transform: translateX(5px); + } + 60% { + transform: translateX(-3px); + } + 80% { + transform: translateX(3px); + } +} + +@utility animate-shake { + animation: ow-shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97); +} + /* Highlight animation for just-saved command */ @keyframes command-highlight { 0% { diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts index ad5fadd1c5..0284713225 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -94,6 +94,13 @@ export default { "composer.placeholder": "Describe your task...", "composer.remote_worker_paste_warning": "This is a remote worker. Sandboxes are remote too. To share files with it, upload them to the Shared folder in the sidebar.", "composer.run_task": "Run task", + "composer.steer": "Steer", + "composer.steer_hint": "Send now and let the agent adjust mid-task", + "composer.queue": "Queue", + "composer.queue_hint": "Send once the agent finishes the current task", + "composer.queued_attachments_only": "{count} attachment(s)", + "composer.queued_count": "{count} queued", + "composer.escape_to_stop": "Hit Escape again to stop the agent", "composer.skill_source": "Skill", "composer.stop": "Stop", "composer.tools_label": "Commands, skills, and MCPs", diff --git a/apps/app/src/react-app/domains/session/modals/queued-messages-panel.tsx b/apps/app/src/react-app/domains/session/modals/queued-messages-panel.tsx new file mode 100644 index 0000000000..69ecaebd7d --- /dev/null +++ b/apps/app/src/react-app/domains/session/modals/queued-messages-panel.tsx @@ -0,0 +1,56 @@ +/** @jsxImportSource react */ +import { ListPlus, X } from "lucide-react"; + +import { t } from "@/i18n"; + +export type QueuedMessagesPanelProps = { + messages: string[]; + onRemove: (index: number) => void; +}; + +/** + * Shows the follow-up messages the user has queued while the agent is busy. + * Rendered above the composer (mirrors the QuestionPanel header style). Each + * entry can be removed with an X. The whole panel hides when the queue is + * empty — callers should simply not render it in that case, but we also guard + * here for safety. + */ +export function QueuedMessagesPanel(props: QueuedMessagesPanelProps) { + if (props.messages.length === 0) return null; + + return ( +
    +
    +
    +
    + +
    +
    + {t("composer.queued_count", { count: props.messages.length })} +
    +
    +
    + +
    + {props.messages.map((message, index) => ( +
    +
    + {message} +
    + +
    + ))} +
    +
    + ); +} diff --git a/apps/app/src/react-app/domains/session/surface/composer/composer.tsx b/apps/app/src/react-app/domains/session/surface/composer/composer.tsx index f761d410c3..5d6306dde7 100644 --- a/apps/app/src/react-app/domains/session/surface/composer/composer.tsx +++ b/apps/app/src/react-app/domains/session/surface/composer/composer.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource react */ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { Agent } from "@opencode-ai/sdk/v2/client"; -import { ArrowUp, ChevronRight, FileText, Paperclip, Plug, Settings, Square, Terminal, X, Zap } from "lucide-react"; +import { ArrowUp, ChevronRight, FileText, ListPlus, Paperclip, Plug, Settings, Square, Terminal, X, Zap } from "lucide-react"; import fuzzysort from "fuzzysort"; import { OPENWORK_EXTENSION_CATALOG, type McpDirectoryInfo } from "../../../../../app/constants"; import type { CloudImportedPlugin, CloudImportedPluginFile } from "../../../../../app/cloud/import-state"; @@ -47,8 +47,11 @@ type ComposerProps = { mentions: Record; onDraftChange: (value: string) => void; onSend: () => void | Promise; + onSteer: () => void | Promise; + onQueue: () => void | Promise; onStop: () => void | Promise; busy: boolean; + queuedCount: number; disabled: boolean; modelUnavailable?: boolean; statusLabel: string; @@ -324,6 +327,50 @@ export function ReactSessionComposer(props: ComposerProps) { draftRef.current = props.draft; }, [props.draft]); + // Follow-up message UX (only relevant while the agent is busy): + // - Enter does NOT submit; instead it shakes the Steer/Queue buttons. + // - Escape arms a "Hit Escape again to stop the agent" prompt for 3s; + // a second Escape within that window stops the agent. + const [followupShake, setFollowupShake] = useState(false); + const [escapeArmed, setEscapeArmed] = useState(false); + const shakeTimerRef = useRef | null>(null); + const escapeTimerRef = useRef | null>(null); + + const triggerFollowupShake = useCallback(() => { + if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current); + setFollowupShake(true); + shakeTimerRef.current = setTimeout(() => setFollowupShake(false), 450); + }, []); + + const disarmEscape = useCallback(() => { + if (escapeTimerRef.current) { + clearTimeout(escapeTimerRef.current); + escapeTimerRef.current = null; + } + setEscapeArmed(false); + }, []); + + // Reset the escape-to-stop prompt whenever the agent stops being busy. + useEffect(() => { + if (!props.busy) disarmEscape(); + }, [props.busy, disarmEscape]); + + useEffect(() => () => { + if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current); + if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current); + }, []); + + // Editor submit (Enter). While idle this sends normally; while busy a + // follow-up message must be explicitly Steered or Queued, so Enter only + // nudges the buttons. + const handleEditorSubmit = useCallback(() => { + if (props.busy) { + triggerFollowupShake(); + return; + } + void props.onSend(); + }, [props.busy, props.onSend, triggerFollowupShake]); + const slashMatch = props.draft.match(/^\/(\S*)$/); const slashOpenNext = Boolean(slashMatch); const slashQuery = slashMatch?.[1] ?? ""; @@ -758,6 +805,25 @@ export function ReactSessionComposer(props: ComposerProps) { if (event.key === "Enter" && imeActive) { return; } + // Escape-to-stop while the agent is busy. Only when no menu is open so + // Escape can still close menus. First press arms a confirmation prompt + // for 3s; a second Escape within that window stops the agent. + const anyMenuOpen = agentMenuOpen || toolMenuOpen || Boolean(activeMenu); + if (event.key === "Escape" && props.busy && !anyMenuOpen) { + event.preventDefault(); + if (escapeArmed) { + disarmEscape(); + void props.onStop(); + } else { + setEscapeArmed(true); + if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current); + escapeTimerRef.current = setTimeout(() => { + setEscapeArmed(false); + escapeTimerRef.current = null; + }, 3000); + } + return; + } if (agentMenuOpen) { const total = agents.length + 1; if (event.key === "ArrowDown") { @@ -1054,7 +1120,7 @@ export function ReactSessionComposer(props: ComposerProps) { disabled={props.disabled} placeholder={t("composer.placeholder")} onChange={props.onDraftChange} - onSubmit={props.onSend} + onSubmit={handleEditorSubmit} onExpandPastedText={handleExpandPastedText} onPasteText={props.onPasteText} onPaste={(event) => { @@ -1369,27 +1435,72 @@ export function ReactSessionComposer(props: ComposerProps) {
    {/* - Single action button that toggles between Stop and Run task. - When busy with no draft: Stop (cancels current run). - When busy with a draft: Run task (queues a follow-up). - When idle: Run task. + Action area. + - Idle: single "Run task" button (sends immediately). + - Busy: follow-up controls — "Steer" sends now (the agent + adjusts mid-task), "Queue" sends once the agent is idle, + and an outline "Stop" cancels the run. Steer/Queue are + disabled until there's something to send. Pressing Enter + while busy shakes Steer/Queue to prompt an explicit choice. + Escape arms a "Hit Escape again to stop the agent" prompt. */}
    - {props.busy && !canSend ? ( - + {props.busy ? ( + <> + {escapeArmed ? ( + + {t("composer.escape_to_stop")} + + ) : null} +
    + + +
    + + ) : (