mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
- 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.
296 lines
11 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|