bolt.diy/app/components/chat/Artifact.tsx
KevIsDev 2e7b626b00 feat: add discuss mode and quick actions
- Implement discuss mode toggle and UI in chat box
- Add quick action buttons for file, message, implement and link actions
- Extend markdown parser to handle quick action elements
- Update message components to support discuss mode and quick actions
- Add discuss prompt for technical consulting responses
- Refactor chat components to support new functionality

The changes introduce a new discuss mode that allows users to switch between code implementation and technical discussion modes. Quick action buttons provide immediate interaction options like opening files, sending messages, or switching modes.
2025-05-26 16:05:51 +01:00

296 lines
11 KiB
TypeScript

import { useStore } from '@nanostores/react';
import { AnimatePresence, motion } from 'framer-motion';
import { computed } from 'nanostores';
import { memo, useEffect, useRef, useState } from 'react';
import { createHighlighter, type BundledLanguage, type BundledTheme, type HighlighterGeneric } from 'shiki';
import type { ActionState } from '~/lib/runtime/action-runner';
import { workbenchStore } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
import { cubicEasingFn } from '~/utils/easings';
import { WORK_DIR } from '~/utils/constants';
const highlighterOptions = {
langs: ['shell'],
themes: ['light-plus', 'dark-plus'],
};
const shellHighlighter: HighlighterGeneric<BundledLanguage, BundledTheme> =
import.meta.hot?.data.shellHighlighter ?? (await createHighlighter(highlighterOptions));
if (import.meta.hot) {
import.meta.hot.data.shellHighlighter = shellHighlighter;
}
interface ArtifactProps {
messageId: string;
}
export const Artifact = memo(({ messageId }: ArtifactProps) => {
const userToggledActions = useRef(false);
const [showActions, setShowActions] = useState(false);
const [allActionFinished, setAllActionFinished] = useState(false);
const artifacts = useStore(workbenchStore.artifacts);
const artifact = artifacts[messageId];
const actions = useStore(
computed(artifact.runner.actions, (actions) => {
// Filter out Supabase actions except for migrations
return Object.values(actions).filter((action) => {
// Exclude actions with type 'supabase' or actions that contain 'supabase' in their content
return action.type !== 'supabase' && !(action.type === 'shell' && action.content?.includes('supabase'));
});
}),
);
const toggleActions = () => {
userToggledActions.current = true;
setShowActions(!showActions);
};
useEffect(() => {
if (actions.length && !showActions && !userToggledActions.current) {
setShowActions(true);
}
if (actions.length !== 0 && artifact.type === 'bundled') {
const finished = !actions.find(
(action) => action.status !== 'complete' && !(action.type === 'start' && action.status === 'running'),
);
if (allActionFinished !== finished) {
setAllActionFinished(finished);
}
}
}, [actions, artifact.type, allActionFinished]);
// Determine the dynamic title based on state for bundled artifacts
const dynamicTitle =
artifact?.type === 'bundled'
? allActionFinished
? artifact.id === 'restored-project-setup'
? 'Project Restored' // Title when restore is complete
: 'Project Created' // Title when initial creation is complete
: artifact.id === 'restored-project-setup'
? 'Restoring Project...' // Title during restore
: 'Creating Project...' // Title during initial creation
: artifact?.title; // Fallback to original title for non-bundled or if artifact is missing
return (
<>
<div className="artifact border border-bolt-elements-borderColor flex flex-col overflow-hidden rounded-lg w-full transition-border duration-150">
<div className="flex">
<button
className="flex items-stretch bg-bolt-elements-artifacts-background hover:bg-bolt-elements-artifacts-backgroundHover w-full overflow-hidden"
onClick={() => {
const showWorkbench = workbenchStore.showWorkbench.get();
workbenchStore.showWorkbench.set(!showWorkbench);
}}
>
<div className="px-5 p-3.5 w-full text-left">
<div className="w-full text-bolt-elements-textPrimary font-medium leading-5 text-sm">
{/* Use the dynamic title here */}
{dynamicTitle}
</div>
<div className="w-full w-full text-bolt-elements-textSecondary text-xs mt-0.5">
Click to open Workbench
</div>
</div>
</button>
{artifact.type !== 'bundled' && <div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />}
<AnimatePresence>
{actions.length && artifact.type !== 'bundled' && (
<motion.button
initial={{ width: 0 }}
animate={{ width: 'auto' }}
exit={{ width: 0 }}
transition={{ duration: 0.15, ease: cubicEasingFn }}
className="bg-bolt-elements-artifacts-background hover:bg-bolt-elements-artifacts-backgroundHover"
onClick={toggleActions}
>
<div className="p-4">
<div className={showActions ? 'i-ph:caret-up-bold' : 'i-ph:caret-down-bold'}></div>
</div>
</motion.button>
)}
</AnimatePresence>
</div>
{artifact.type === 'bundled' && (
<div className="flex items-center gap-1.5 p-5 bg-bolt-elements-actions-background border-t border-bolt-elements-artifacts-borderColor">
<div className={classNames('text-lg', getIconColor(allActionFinished ? 'complete' : 'running'))}>
{allActionFinished ? (
<div className="i-ph:check"></div>
) : (
<div className="i-svg-spinners:90-ring-with-bg"></div>
)}
</div>
<div className="text-bolt-elements-textPrimary font-medium leading-5 text-sm">
{/* This status text remains the same */}
{allActionFinished
? artifact.id === 'restored-project-setup'
? 'Restore files from snapshot'
: 'Initial files created'
: 'Creating initial files'}
</div>
</div>
)}
<AnimatePresence>
{artifact.type !== 'bundled' && showActions && actions.length > 0 && (
<motion.div
className="actions"
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: '0px' }}
transition={{ duration: 0.15 }}
>
<div className="bg-bolt-elements-artifacts-borderColor h-[1px]" />
<div className="p-5 text-left bg-bolt-elements-actions-background">
<ActionList actions={actions} />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</>
);
});
interface ShellCodeBlockProps {
classsName?: string;
code: string;
}
function ShellCodeBlock({ classsName, code }: ShellCodeBlockProps) {
return (
<div
className={classNames('text-xs', classsName)}
dangerouslySetInnerHTML={{
__html: shellHighlighter.codeToHtml(code, {
lang: 'shell',
theme: 'dark-plus',
}),
}}
></div>
);
}
interface ActionListProps {
actions: ActionState[];
}
const actionVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
};
export function openArtifactInWorkbench(filePath: any) {
if (workbenchStore.currentView.get() !== 'code') {
workbenchStore.currentView.set('code');
}
workbenchStore.setSelectedFile(`${WORK_DIR}/${filePath}`);
}
const ActionList = memo(({ actions }: ActionListProps) => {
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
<ul className="list-none space-y-2.5">
{actions.map((action, index) => {
const { status, type, content } = action;
const isLast = index === actions.length - 1;
return (
<motion.li
key={index}
variants={actionVariants}
initial="hidden"
animate="visible"
transition={{
duration: 0.2,
ease: cubicEasingFn,
}}
>
<div className="flex items-center gap-1.5 text-sm">
<div className={classNames('text-lg', getIconColor(action.status))}>
{status === 'running' ? (
<>
{type !== 'start' ? (
<div className="i-svg-spinners:90-ring-with-bg"></div>
) : (
<div className="i-ph:terminal-window-duotone"></div>
)}
</>
) : status === 'pending' ? (
<div className="i-ph:circle-duotone"></div>
) : status === 'complete' ? (
<div className="i-ph:check"></div>
) : status === 'failed' || status === 'aborted' ? (
<div className="i-ph:x"></div>
) : null}
</div>
{type === 'file' ? (
<div>
Create{' '}
<code
className="bg-bolt-elements-artifacts-inlineCode-background text-bolt-elements-artifacts-inlineCode-text px-1.5 py-1 rounded-md text-bolt-elements-item-contentAccent hover:underline cursor-pointer"
onClick={() => openArtifactInWorkbench(action.filePath)}
>
{action.filePath}
</code>
</div>
) : type === 'shell' ? (
<div className="flex items-center w-full min-h-[28px]">
<span className="flex-1">Run command</span>
</div>
) : type === 'start' ? (
<a
onClick={(e) => {
e.preventDefault();
workbenchStore.currentView.set('preview');
}}
className="flex items-center w-full min-h-[28px]"
>
<span className="flex-1">Start Application</span>
</a>
) : null}
</div>
{(type === 'shell' || type === 'start') && (
<ShellCodeBlock
classsName={classNames('mt-1', {
'mb-3.5': !isLast,
})}
code={content}
/>
)}
</motion.li>
);
})}
</ul>
</motion.div>
);
});
function getIconColor(status: ActionState['status']) {
switch (status) {
case 'pending': {
return 'text-bolt-elements-textTertiary';
}
case 'running': {
return 'text-bolt-elements-loader-progress';
}
case 'complete': {
return 'text-bolt-elements-icon-success';
}
case 'aborted': {
return 'text-bolt-elements-textSecondary';
}
case 'failed': {
return 'text-bolt-elements-icon-error';
}
default: {
return undefined;
}
}
}