Dominic Elm commited on
Commit
012b5ba
·
1 Parent(s): 637ad2b

feat: improve prompt, add ability to abort streaming, improve message parser

Browse files
packages/bolt/app/components/chat/Artifact.tsx CHANGED
@@ -1,8 +1,9 @@
1
  import { useStore } from '@nanostores/react';
2
  import { AnimatePresence, motion } from 'framer-motion';
3
  import { computed } from 'nanostores';
4
- import { useState } from 'react';
5
  import { createHighlighter, type BundledLanguage, type BundledTheme, type HighlighterGeneric } from 'shiki';
 
6
  import { getArtifactKey, workbenchStore, type ActionState } from '../../lib/stores/workbench';
7
  import { classNames } from '../../utils/classNames';
8
  import { cubicEasingFn } from '../../utils/easings';
@@ -25,9 +26,11 @@ interface ArtifactProps {
25
  messageId: string;
26
  }
27
 
28
- export function Artifact({ artifactId, messageId }: ArtifactProps) {
 
29
  const [showActions, setShowActions] = useState(false);
30
 
 
31
  const artifacts = useStore(workbenchStore.artifacts);
32
  const artifact = artifacts[getArtifactKey(artifactId, messageId)];
33
 
@@ -37,6 +40,17 @@ export function Artifact({ artifactId, messageId }: ArtifactProps) {
37
  }),
38
  );
39
 
 
 
 
 
 
 
 
 
 
 
 
40
  return (
41
  <div className="flex flex-col overflow-hidden border rounded-lg w-full">
42
  <div className="flex">
@@ -48,7 +62,7 @@ export function Artifact({ artifactId, messageId }: ArtifactProps) {
48
  }}
49
  >
50
  <div className="flex items-center px-6 bg-gray-100/50">
51
- {!artifact?.closed ? (
52
  <div className="i-svg-spinners:90-ring-with-bg scale-130"></div>
53
  ) : (
54
  <div className="i-ph:code-bold scale-130 text-gray-600"></div>
@@ -67,7 +81,7 @@ export function Artifact({ artifactId, messageId }: ArtifactProps) {
67
  exit={{ width: 0 }}
68
  transition={{ duration: 0.15, ease: cubicEasingFn }}
69
  className="hover:bg-gray-200"
70
- onClick={() => setShowActions(!showActions)}
71
  >
72
  <div className="p-4">
73
  <div className={showActions ? 'i-ph:caret-up-bold' : 'i-ph:caret-down-bold'}></div>
@@ -98,9 +112,9 @@ export function Artifact({ artifactId, messageId }: ArtifactProps) {
98
  const { status, type, content, abort } = action;
99
 
100
  return (
101
- <li key={index} className={classNames(getTextColor(action.status))}>
102
  <div className="flex items-center gap-1.5">
103
- <div className="text-lg">
104
  {status === 'running' ? (
105
  <div className="i-svg-spinners:90-ring-with-bg"></div>
106
  ) : status === 'pending' ? (
@@ -136,7 +150,7 @@ export function Artifact({ artifactId, messageId }: ArtifactProps) {
136
  </AnimatePresence>
137
  </div>
138
  );
139
- }
140
 
141
  function getTextColor(status: ActionState['status']) {
142
  switch (status) {
 
1
  import { useStore } from '@nanostores/react';
2
  import { AnimatePresence, motion } from 'framer-motion';
3
  import { computed } from 'nanostores';
4
+ import { memo, useEffect, useRef, useState } from 'react';
5
  import { createHighlighter, type BundledLanguage, type BundledTheme, type HighlighterGeneric } from 'shiki';
6
+ import { chatStore } from '../../lib/stores/chat';
7
  import { getArtifactKey, workbenchStore, type ActionState } from '../../lib/stores/workbench';
8
  import { classNames } from '../../utils/classNames';
9
  import { cubicEasingFn } from '../../utils/easings';
 
26
  messageId: string;
27
  }
28
 
29
+ export const Artifact = memo(({ artifactId, messageId }: ArtifactProps) => {
30
+ const userToggledActions = useRef(false);
31
  const [showActions, setShowActions] = useState(false);
32
 
33
+ const chat = useStore(chatStore);
34
  const artifacts = useStore(workbenchStore.artifacts);
35
  const artifact = artifacts[getArtifactKey(artifactId, messageId)];
36
 
 
40
  }),
41
  );
42
 
43
+ const toggleActions = () => {
44
+ userToggledActions.current = true;
45
+ setShowActions(!showActions);
46
+ };
47
+
48
+ useEffect(() => {
49
+ if (actions.length && !showActions && !userToggledActions.current) {
50
+ setShowActions(true);
51
+ }
52
+ }, [actions]);
53
+
54
  return (
55
  <div className="flex flex-col overflow-hidden border rounded-lg w-full">
56
  <div className="flex">
 
62
  }}
63
  >
64
  <div className="flex items-center px-6 bg-gray-100/50">
65
+ {!artifact?.closed && !chat.aborted ? (
66
  <div className="i-svg-spinners:90-ring-with-bg scale-130"></div>
67
  ) : (
68
  <div className="i-ph:code-bold scale-130 text-gray-600"></div>
 
81
  exit={{ width: 0 }}
82
  transition={{ duration: 0.15, ease: cubicEasingFn }}
83
  className="hover:bg-gray-200"
84
+ onClick={toggleActions}
85
  >
86
  <div className="p-4">
87
  <div className={showActions ? 'i-ph:caret-up-bold' : 'i-ph:caret-down-bold'}></div>
 
112
  const { status, type, content, abort } = action;
113
 
114
  return (
115
+ <li key={index}>
116
  <div className="flex items-center gap-1.5">
117
+ <div className={classNames('text-lg', getTextColor(action.status))}>
118
  {status === 'running' ? (
119
  <div className="i-svg-spinners:90-ring-with-bg"></div>
120
  ) : status === 'pending' ? (
 
150
  </AnimatePresence>
151
  </div>
152
  );
153
+ });
154
 
155
  function getTextColor(status: ActionState['status']) {
156
  switch (status) {
packages/bolt/app/components/chat/BaseChat.tsx CHANGED
@@ -16,6 +16,7 @@ interface BaseChatProps {
16
  enhancingPrompt?: boolean;
17
  promptEnhanced?: boolean;
18
  input?: string;
 
19
  sendMessage?: () => void;
20
  handleInputChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
21
  enhancePrompt?: () => void;
@@ -38,6 +39,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
38
  sendMessage,
39
  handleInputChange,
40
  enhancePrompt,
 
41
  },
42
  ref,
43
  ) => {
@@ -111,7 +113,22 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
111
  placeholder="How can Bolt help you today?"
112
  translate="no"
113
  />
114
- <ClientOnly>{() => <SendButton show={input.length > 0} onClick={sendMessage} />}</ClientOnly>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  <div className="flex justify-between text-sm p-4 pt-2">
116
  <div className="flex gap-1 items-center">
117
  <IconButton icon="i-ph:microphone-duotone" className="-ml-1" />
 
16
  enhancingPrompt?: boolean;
17
  promptEnhanced?: boolean;
18
  input?: string;
19
+ handleStop?: () => void;
20
  sendMessage?: () => void;
21
  handleInputChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
22
  enhancePrompt?: () => void;
 
39
  sendMessage,
40
  handleInputChange,
41
  enhancePrompt,
42
+ handleStop,
43
  },
44
  ref,
45
  ) => {
 
113
  placeholder="How can Bolt help you today?"
114
  translate="no"
115
  />
116
+ <ClientOnly>
117
+ {() => (
118
+ <SendButton
119
+ show={input.length > 0 || isStreaming}
120
+ isStreaming={isStreaming}
121
+ onClick={() => {
122
+ if (isStreaming) {
123
+ handleStop?.();
124
+ return;
125
+ }
126
+
127
+ sendMessage?.();
128
+ }}
129
+ />
130
+ )}
131
+ </ClientOnly>
132
  <div className="flex justify-between text-sm p-4 pt-2">
133
  <div className="flex gap-1 items-center">
134
  <IconButton icon="i-ph:microphone-duotone" className="-ml-1" />
packages/bolt/app/components/chat/Chat.client.tsx CHANGED
@@ -2,6 +2,8 @@ import { useChat } from 'ai/react';
2
  import { useAnimate } from 'framer-motion';
3
  import { useEffect, useRef, useState } from 'react';
4
  import { useMessageParser, usePromptEnhancer } from '../../lib/hooks';
 
 
5
  import { cubicEasingFn } from '../../utils/easings';
6
  import { createScopedLogger } from '../../utils/logger';
7
  import { BaseChat } from './BaseChat';
@@ -15,7 +17,7 @@ export function Chat() {
15
 
16
  const [animationScope, animate] = useAnimate();
17
 
18
- const { messages, isLoading, input, handleInputChange, setInput, handleSubmit } = useChat({
19
  api: '/api/chat',
20
  onError: (error) => {
21
  logger.error(error);
@@ -42,6 +44,12 @@ export function Chat() {
42
  }
43
  };
44
 
 
 
 
 
 
 
45
  useEffect(() => {
46
  const textarea = textareaRef.current;
47
 
@@ -70,6 +78,8 @@ export function Chat() {
70
  return;
71
  }
72
 
 
 
73
  runAnimation();
74
  handleSubmit();
75
  resetEnhancer();
@@ -88,6 +98,7 @@ export function Chat() {
88
  promptEnhanced={promptEnhanced}
89
  sendMessage={sendMessage}
90
  handleInputChange={handleInputChange}
 
91
  messages={messages.map((message, i) => {
92
  if (message.role === 'user') {
93
  return message;
 
2
  import { useAnimate } from 'framer-motion';
3
  import { useEffect, useRef, useState } from 'react';
4
  import { useMessageParser, usePromptEnhancer } from '../../lib/hooks';
5
+ import { chatStore } from '../../lib/stores/chat';
6
+ import { workbenchStore } from '../../lib/stores/workbench';
7
  import { cubicEasingFn } from '../../utils/easings';
8
  import { createScopedLogger } from '../../utils/logger';
9
  import { BaseChat } from './BaseChat';
 
17
 
18
  const [animationScope, animate] = useAnimate();
19
 
20
+ const { messages, isLoading, input, handleInputChange, setInput, handleSubmit, stop } = useChat({
21
  api: '/api/chat',
22
  onError: (error) => {
23
  logger.error(error);
 
44
  }
45
  };
46
 
47
+ const abort = () => {
48
+ stop();
49
+ chatStore.setKey('aborted', true);
50
+ workbenchStore.abortAllActions();
51
+ };
52
+
53
  useEffect(() => {
54
  const textarea = textareaRef.current;
55
 
 
78
  return;
79
  }
80
 
81
+ chatStore.setKey('aborted', false);
82
+
83
  runAnimation();
84
  handleSubmit();
85
  resetEnhancer();
 
98
  promptEnhanced={promptEnhanced}
99
  sendMessage={sendMessage}
100
  handleInputChange={handleInputChange}
101
+ handleStop={abort}
102
  messages={messages.map((message, i) => {
103
  if (message.role === 'user') {
104
  return message;
packages/bolt/app/components/chat/SendButton.client.tsx CHANGED
@@ -2,12 +2,13 @@ import { AnimatePresence, cubicBezier, motion } from 'framer-motion';
2
 
3
  interface SendButtonProps {
4
  show: boolean;
 
5
  onClick?: VoidFunction;
6
  }
7
 
8
  const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
9
 
10
- export function SendButton({ show, onClick }: SendButtonProps) {
11
  return (
12
  <AnimatePresence>
13
  {show ? (
@@ -22,7 +23,9 @@ export function SendButton({ show, onClick }: SendButtonProps) {
22
  onClick?.();
23
  }}
24
  >
25
- <div className="i-ph:arrow-right text-xl"></div>
 
 
26
  </motion.button>
27
  ) : null}
28
  </AnimatePresence>
 
2
 
3
  interface SendButtonProps {
4
  show: boolean;
5
+ isStreaming?: boolean;
6
  onClick?: VoidFunction;
7
  }
8
 
9
  const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
10
 
11
+ export function SendButton({ show, isStreaming, onClick }: SendButtonProps) {
12
  return (
13
  <AnimatePresence>
14
  {show ? (
 
23
  onClick?.();
24
  }}
25
  >
26
+ <div className="text-lg">
27
+ {!isStreaming ? <div className="i-ph:arrow-right"></div> : <div className="i-ph:stop-circle-bold"></div>}
28
+ </div>
29
  </motion.button>
30
  ) : null}
31
  </AnimatePresence>
packages/bolt/app/components/workbench/Preview.tsx CHANGED
@@ -30,12 +30,13 @@ export const Preview = memo(() => {
30
  return (
31
  <div className="w-full h-full flex flex-col">
32
  <div className="bg-gray-100 rounded-t-lg p-2 flex items-center space-x-1.5">
33
- <div className="i-ph:circle-fill text-[#FF5F57]"></div>
34
- <div className="i-ph:circle-fill text-[#FEBC2E]"></div>
35
- <div className="i-ph:circle-fill text-[#29CC41]"></div>
 
36
  <div className="flex-grow"></div>
37
  </div>
38
- <div className="bg-white p-2 flex items-center gap-1">
39
  <IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
40
  <div className="flex items-center gap-1 flex-grow bg-gray-100 rounded-full px-3 py-1 text-sm text-gray-600 hover:bg-gray-200 hover:focus-within:bg-white focus-within:bg-white focus-within:ring-2 focus-within:ring-accent">
41
  <div className="bg-white rounded-full p-[2px] -ml-1">
 
30
  return (
31
  <div className="w-full h-full flex flex-col">
32
  <div className="bg-gray-100 rounded-t-lg p-2 flex items-center space-x-1.5">
33
+ <div className="flex items-center gap-2 text-gray-800">
34
+ <div className="i-ph:app-window-duotone scale-130 ml-1.5"></div>
35
+ <span className="text-sm">Preview</span>
36
+ </div>
37
  <div className="flex-grow"></div>
38
  </div>
39
+ <div className="bg-white p-2 flex items-center gap-1.5">
40
  <IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
41
  <div className="flex items-center gap-1 flex-grow bg-gray-100 rounded-full px-3 py-1 text-sm text-gray-600 hover:bg-gray-200 hover:focus-within:bg-white focus-within:bg-white focus-within:ring-2 focus-within:ring-accent">
42
  <div className="bg-white rounded-full p-[2px] -ml-1">
packages/bolt/app/lib/.server/llm/prompts.ts CHANGED
@@ -10,24 +10,13 @@ You are Bolt, an expert AI assistant and exceptional senior software developer w
10
 
11
  IMPORTANT: Git is NOT available.
12
 
13
- Available shell commands: ['cat','chmod','cp','echo','hostname','kill','ln','ls','mkdir','mv','ps','pwd','rm','rmdir','xxd','alias','cd','clear','curl','env','false','getconf','head','sort','tail','touch','true','uptime','which','code','jq','loadenv','node','python3','wasm','xdg-open','command','exit','export','source']
14
  </system_constraints>
15
 
16
  <code_formatting_info>
17
  Use 2 spaces for code indentation
18
  </code_formatting_info>
19
 
20
- <best_practices>
21
- Follow coding best practices:
22
- - Ensure code is clean, readable, and maintainable.
23
- - Adhere to proper naming conventions and consistent formatting.
24
-
25
- Modularize functionality:
26
- - Split functionality into smaller, reusable modules instead of placing everything in a single large file.
27
- - Keep files as small as possible by extracting related functionalities into separate modules.
28
- - Use imports to connect these modules together effectively.
29
- </best_practices>
30
-
31
  <artifact_info>
32
  Bolt creates a SINGLE, comprehensive artifact for each project. The artifact contains all necessary steps and components, including:
33
 
@@ -67,6 +56,16 @@ You are Bolt, an expert AI assistant and exceptional senior software developer w
67
  10. Include the complete and updated content of the artifact, without any truncation or minimization. Don't use "// rest of the code remains the same...".
68
 
69
  11. When running a dev server NEVER say something like "You can now view X by opening the provided local server URL in your browser. The preview will be opened automatically or by the user manually!
 
 
 
 
 
 
 
 
 
 
70
  </artifact_instructions>
71
  </artifact_info>
72
 
 
10
 
11
  IMPORTANT: Git is NOT available.
12
 
13
+ Available shell commands: cat, chmod, cp, echo, hostname, kill, ln, ls, mkdir, mv, ps, pwd, rm, rmdir, xxd, alias, cd, clear, curl, env, false, getconf, head, sort, tail, touch, true, uptime, which, code, jq, loadenv, node, python3, wasm, xdg-open, command, exit, export, source
14
  </system_constraints>
15
 
16
  <code_formatting_info>
17
  Use 2 spaces for code indentation
18
  </code_formatting_info>
19
 
 
 
 
 
 
 
 
 
 
 
 
20
  <artifact_info>
21
  Bolt creates a SINGLE, comprehensive artifact for each project. The artifact contains all necessary steps and components, including:
22
 
 
56
  10. Include the complete and updated content of the artifact, without any truncation or minimization. Don't use "// rest of the code remains the same...".
57
 
58
  11. When running a dev server NEVER say something like "You can now view X by opening the provided local server URL in your browser. The preview will be opened automatically or by the user manually!
59
+
60
+ 12. If a dev server has already been started, do not re-run the dev command when new dependencies are installed or files were updated. Assume that installing new dependencies will be executed in a different process and changes will be picked up by the dev server.
61
+
62
+ 13. ULTRA IMPORTANT: Use coding best practices and split functionality into smaller modules instead of putting everything in a single gigantic file. Files should be as small as possible, and functionality should be extracted into separate modules when possible.
63
+
64
+ - Ensure code is clean, readable, and maintainable.
65
+ - Adhere to proper naming conventions and consistent formatting.
66
+ - Split functionality into smaller, reusable modules instead of placing everything in a single large file.
67
+ - Keep files as small as possible by extracting related functionalities into separate modules.
68
+ - Use imports to connect these modules together effectively.
69
  </artifact_instructions>
70
  </artifact_info>
71
 
packages/bolt/app/lib/hooks/useMessageParser.ts CHANGED
@@ -19,8 +19,20 @@ const messageParser = new StreamingMessageParser({
19
 
20
  workbenchStore.updateArtifact(data, { closed: true });
21
  },
22
- onAction: (data) => {
23
- logger.debug('onAction', data);
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
  workbenchStore.runAction(data);
26
  },
 
19
 
20
  workbenchStore.updateArtifact(data, { closed: true });
21
  },
22
+ onActionOpen: (data) => {
23
+ logger.debug('onActionOpen', data.action);
24
+
25
+ // we only add shell actions when when the close tag got parsed because only then we have the content
26
+ if (data.action.type !== 'shell') {
27
+ workbenchStore.addAction(data);
28
+ }
29
+ },
30
+ onActionClose: (data) => {
31
+ logger.debug('onActionClose', data.action);
32
+
33
+ if (data.action.type === 'shell') {
34
+ workbenchStore.addAction(data);
35
+ }
36
 
37
  workbenchStore.runAction(data);
38
  },
packages/bolt/app/lib/runtime/__snapshots__/message-parser.spec.ts.snap ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (0) > onActionClose 1`] = `
4
+ {
5
+ "action": {
6
+ "content": "npm install",
7
+ "type": "shell",
8
+ },
9
+ "actionId": "0",
10
+ "artifactId": "artifact_1",
11
+ "messageId": "message_1",
12
+ }
13
+ `;
14
+
15
+ exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (0) > onActionOpen 1`] = `
16
+ {
17
+ "action": {
18
+ "content": "",
19
+ "type": "shell",
20
+ },
21
+ "actionId": "0",
22
+ "artifactId": "artifact_1",
23
+ "messageId": "message_1",
24
+ }
25
+ `;
26
+
27
+ exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (0) > onArtifactClose 1`] = `
28
+ {
29
+ "id": "artifact_1",
30
+ "messageId": "message_1",
31
+ "title": "Some title",
32
+ }
33
+ `;
34
+
35
+ exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (0) > onArtifactOpen 1`] = `
36
+ {
37
+ "id": "artifact_1",
38
+ "messageId": "message_1",
39
+ "title": "Some title",
40
+ }
41
+ `;
42
+
43
+ exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (1) > onActionClose 1`] = `
44
+ {
45
+ "action": {
46
+ "content": "npm install",
47
+ "type": "shell",
48
+ },
49
+ "actionId": "0",
50
+ "artifactId": "artifact_1",
51
+ "messageId": "message_1",
52
+ }
53
+ `;
54
+
55
+ exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (1) > onActionClose 2`] = `
56
+ {
57
+ "action": {
58
+ "content": "some content
59
+ ",
60
+ "filePath": "index.js",
61
+ "type": "file",
62
+ },
63
+ "actionId": "1",
64
+ "artifactId": "artifact_1",
65
+ "messageId": "message_1",
66
+ }
67
+ `;
68
+
69
+ exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (1) > onActionOpen 1`] = `
70
+ {
71
+ "action": {
72
+ "content": "",
73
+ "type": "shell",
74
+ },
75
+ "actionId": "0",
76
+ "artifactId": "artifact_1",
77
+ "messageId": "message_1",
78
+ }
79
+ `;
80
+
81
+ exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (1) > onActionOpen 2`] = `
82
+ {
83
+ "action": {
84
+ "content": "",
85
+ "filePath": "index.js",
86
+ "type": "file",
87
+ },
88
+ "actionId": "1",
89
+ "artifactId": "artifact_1",
90
+ "messageId": "message_1",
91
+ }
92
+ `;
93
+
94
+ exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (1) > onArtifactClose 1`] = `
95
+ {
96
+ "id": "artifact_1",
97
+ "messageId": "message_1",
98
+ "title": "Some title",
99
+ }
100
+ `;
101
+
102
+ exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (1) > onArtifactOpen 1`] = `
103
+ {
104
+ "id": "artifact_1",
105
+ "messageId": "message_1",
106
+ "title": "Some title",
107
+ }
108
+ `;
109
+
110
+ exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (0) > onArtifactClose 1`] = `
111
+ {
112
+ "id": "artifact_1",
113
+ "messageId": "message_1",
114
+ "title": "Some title",
115
+ }
116
+ `;
117
+
118
+ exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (0) > onArtifactOpen 1`] = `
119
+ {
120
+ "id": "artifact_1",
121
+ "messageId": "message_1",
122
+ "title": "Some title",
123
+ }
124
+ `;
125
+
126
+ exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (1) > onArtifactClose 1`] = `
127
+ {
128
+ "id": "artifact_1",
129
+ "messageId": "message_1",
130
+ "title": "Some title",
131
+ }
132
+ `;
133
+
134
+ exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (1) > onArtifactOpen 1`] = `
135
+ {
136
+ "id": "artifact_1",
137
+ "messageId": "message_1",
138
+ "title": "Some title",
139
+ }
140
+ `;
141
+
142
+ exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (2) > onArtifactClose 1`] = `
143
+ {
144
+ "id": "artifact_1",
145
+ "messageId": "message_1",
146
+ "title": "Some title",
147
+ }
148
+ `;
149
+
150
+ exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (2) > onArtifactOpen 1`] = `
151
+ {
152
+ "id": "artifact_1",
153
+ "messageId": "message_1",
154
+ "title": "Some title",
155
+ }
156
+ `;
157
+
158
+ exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (3) > onArtifactClose 1`] = `
159
+ {
160
+ "id": "artifact_1",
161
+ "messageId": "message_1",
162
+ "title": "Some title",
163
+ }
164
+ `;
165
+
166
+ exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (3) > onArtifactOpen 1`] = `
167
+ {
168
+ "id": "artifact_1",
169
+ "messageId": "message_1",
170
+ "title": "Some title",
171
+ }
172
+ `;
173
+
174
+ exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (4) > onArtifactClose 1`] = `
175
+ {
176
+ "id": "artifact_1",
177
+ "messageId": "message_1",
178
+ "title": "Some title",
179
+ }
180
+ `;
181
+
182
+ exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (4) > onArtifactOpen 1`] = `
183
+ {
184
+ "id": "artifact_1",
185
+ "messageId": "message_1",
186
+ "title": "Some title",
187
+ }
188
+ `;
189
+
190
+ exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (5) > onArtifactClose 1`] = `
191
+ {
192
+ "id": "artifact_1",
193
+ "messageId": "message_1",
194
+ "title": "Some title",
195
+ }
196
+ `;
197
+
198
+ exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (5) > onArtifactOpen 1`] = `
199
+ {
200
+ "id": "artifact_1",
201
+ "messageId": "message_1",
202
+ "title": "Some title",
203
+ }
204
+ `;
205
+
206
+ exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (6) > onArtifactClose 1`] = `
207
+ {
208
+ "id": "artifact_1",
209
+ "messageId": "message_1",
210
+ "title": "Some title",
211
+ }
212
+ `;
213
+
214
+ exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (6) > onArtifactOpen 1`] = `
215
+ {
216
+ "id": "artifact_1",
217
+ "messageId": "message_1",
218
+ "title": "Some title",
219
+ }
220
+ `;
packages/bolt/app/lib/runtime/message-parser.spec.ts CHANGED
@@ -1,5 +1,15 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { StreamingMessageParser } from './message-parser';
 
 
 
 
 
 
 
 
 
 
3
 
4
  describe('StreamingMessageParser', () => {
5
  it('should pass through normal text', () => {
@@ -12,75 +22,186 @@ describe('StreamingMessageParser', () => {
12
  expect(parser.parse('test_id', 'Hello <strong>world</strong>!')).toBe('Hello <strong>world</strong>!');
13
  });
14
 
15
- it.each([
16
- ['Foo bar', 'Foo bar'],
17
- ['Foo bar <', 'Foo bar '],
18
- ['Foo bar <p', 'Foo bar <p'],
19
- ['Foo bar <b', 'Foo bar '],
20
- ['Foo bar <ba', 'Foo bar <ba'],
21
- ['Foo bar <bol', 'Foo bar '],
22
- ['Foo bar <bolt', 'Foo bar '],
23
- ['Foo bar <bolta', 'Foo bar <bolta'],
24
- ['Foo bar <boltA', 'Foo bar '],
25
- ['Some text before <boltArtifact>foo</boltArtifact> Some more text', 'Some text before Some more text'],
26
- [['Some text before <boltArti', 'fact>foo</boltArtifact> Some more text'], 'Some text before Some more text'],
27
- [['Some text before <boltArti', 'fac', 't>foo</boltArtifact> Some more text'], 'Some text before Some more text'],
28
- [['Some text before <boltArti', 'fact>fo', 'o</boltArtifact> Some more text'], 'Some text before Some more text'],
29
- [
30
- ['Some text before <boltArti', 'fact>fo', 'o', '<', '/boltArtifact> Some more text'],
31
- 'Some text before Some more text',
32
- ],
33
- [
34
- ['Some text before <boltArti', 'fact>fo', 'o<', '/boltArtifact> Some more text'],
35
- 'Some text before Some more text',
36
- ],
37
- ['Before <oltArtfiact>foo</boltArtifact> After', 'Before <oltArtfiact>foo</boltArtifact> After'],
38
- ['Before <boltArtifactt>foo</boltArtifact> After', 'Before <boltArtifactt>foo</boltArtifact> After'],
39
- ['Before <boltArtifact title="Some title">foo</boltArtifact> After', 'Before After'],
40
- [
41
- 'Before <boltArtifact title="Some title" id="artifact_1"><boltAction type="shell">npm install</boltAction></boltArtifact> After',
42
- 'Before After',
43
- [{ type: 'shell', content: 'npm install' }],
44
- ],
45
- [
46
- 'Before <boltArtifact title="Some title" id="artifact_1"><boltAction type="shell">npm install</boltAction><boltAction type="file" filePath="index.js">some content</boltAction></boltArtifact> After',
47
- 'Before After',
 
 
 
48
  [
49
- { type: 'shell', content: 'npm install' },
50
- { type: 'file', filePath: 'index.js', content: 'some content\n' },
 
 
 
51
  ],
52
- ],
53
- ])('should correctly parse chunks and strip out bolt artifacts', (input, expected, expectedActions = []) => {
54
- let actionCounter = 0;
55
-
56
- const expectedArtifactId = 'artifact_1';
57
- const expectedMessageId = 'message_1';
58
-
59
- const parser = new StreamingMessageParser({
60
- artifactElement: '',
61
- callbacks: {
62
- onAction: ({ artifactId, messageId, action }) => {
63
- expect(artifactId).toBe(expectedArtifactId);
64
- expect(messageId).toBe(expectedMessageId);
65
- expect(action).toEqual(expectedActions[actionCounter]);
66
- actionCounter++;
67
  },
68
- },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  });
 
70
 
71
- let message = '';
72
-
73
- let result = '';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
- const chunks = Array.isArray(input) ? input : input.split('');
 
76
 
77
- for (const chunk of chunks) {
78
- message += chunk;
 
 
 
79
 
80
- result += parser.parse(expectedMessageId, message);
81
- }
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
- expect(actionCounter).toBe(expectedActions.length);
84
- expect(result).toEqual(expected);
 
85
  });
86
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { StreamingMessageParser, type ActionCallback, type ArtifactCallback } from './message-parser';
3
+
4
+ interface ExpectedResult {
5
+ output: string;
6
+ callbacks?: {
7
+ onArtifactOpen?: number;
8
+ onArtifactClose?: number;
9
+ onActionOpen?: number;
10
+ onActionClose?: number;
11
+ };
12
+ }
13
 
14
  describe('StreamingMessageParser', () => {
15
  it('should pass through normal text', () => {
 
22
  expect(parser.parse('test_id', 'Hello <strong>world</strong>!')).toBe('Hello <strong>world</strong>!');
23
  });
24
 
25
+ describe('no artifacts', () => {
26
+ it.each<[string | string[], ExpectedResult | string]>([
27
+ ['Foo bar', 'Foo bar'],
28
+ ['Foo bar <', 'Foo bar '],
29
+ ['Foo bar <p', 'Foo bar <p'],
30
+ [['Foo bar <', 's', 'p', 'an>some text</span>'], 'Foo bar <span>some text</span>'],
31
+ ])('should correctly parse chunks and strip out bolt artifacts (%#)', (input, expected) => {
32
+ runTest(input, expected);
33
+ });
34
+ });
35
+
36
+ describe('invalid or incomplete artifacts', () => {
37
+ it.each<[string | string[], ExpectedResult | string]>([
38
+ ['Foo bar <b', 'Foo bar '],
39
+ ['Foo bar <ba', 'Foo bar <ba'],
40
+ ['Foo bar <bol', 'Foo bar '],
41
+ ['Foo bar <bolt', 'Foo bar '],
42
+ ['Foo bar <bolta', 'Foo bar <bolta'],
43
+ ['Foo bar <boltA', 'Foo bar '],
44
+ ['Foo bar <boltArtifacs></boltArtifact>', 'Foo bar <boltArtifacs></boltArtifact>'],
45
+ ['Before <oltArtfiact>foo</boltArtifact> After', 'Before <oltArtfiact>foo</boltArtifact> After'],
46
+ ['Before <boltArtifactt>foo</boltArtifact> After', 'Before <boltArtifactt>foo</boltArtifact> After'],
47
+ ])('should correctly parse chunks and strip out bolt artifacts (%#)', (input, expected) => {
48
+ runTest(input, expected);
49
+ });
50
+ });
51
+
52
+ describe('valid artifacts without actions', () => {
53
+ it.each<[string | string[], ExpectedResult | string]>([
54
+ [
55
+ 'Some text before <boltArtifact title="Some title" id="artifact_1">foo bar</boltArtifact> Some more text',
56
+ {
57
+ output: 'Some text before Some more text',
58
+ callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
59
+ },
60
+ ],
61
  [
62
+ ['Some text before <boltArti', 'fact', ' title="Some title" id="artifact_1">foo</boltArtifact> Some more text'],
63
+ {
64
+ output: 'Some text before Some more text',
65
+ callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
66
+ },
67
  ],
68
+ [
69
+ [
70
+ 'Some text before <boltArti',
71
+ 'fac',
72
+ 't title="Some title" id="artifact_1"',
73
+ ' ',
74
+ '>',
75
+ 'foo</boltArtifact> Some more text',
76
+ ],
77
+ {
78
+ output: 'Some text before Some more text',
79
+ callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
 
 
 
80
  },
81
+ ],
82
+ [
83
+ [
84
+ 'Some text before <boltArti',
85
+ 'fact',
86
+ ' title="Some title" id="artifact_1"',
87
+ ' >fo',
88
+ 'o</boltArtifact> Some more text',
89
+ ],
90
+ {
91
+ output: 'Some text before Some more text',
92
+ callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
93
+ },
94
+ ],
95
+ [
96
+ [
97
+ 'Some text before <boltArti',
98
+ 'fact tit',
99
+ 'le="Some ',
100
+ 'title" id="artifact_1">fo',
101
+ 'o',
102
+ '<',
103
+ '/boltArtifact> Some more text',
104
+ ],
105
+ {
106
+ output: 'Some text before Some more text',
107
+ callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
108
+ },
109
+ ],
110
+ [
111
+ [
112
+ 'Some text before <boltArti',
113
+ 'fact title="Some title" id="artif',
114
+ 'act_1">fo',
115
+ 'o<',
116
+ '/boltArtifact> Some more text',
117
+ ],
118
+ {
119
+ output: 'Some text before Some more text',
120
+ callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
121
+ },
122
+ ],
123
+ [
124
+ 'Before <boltArtifact title="Some title" id="artifact_1">foo</boltArtifact> After',
125
+ {
126
+ output: 'Before After',
127
+ callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
128
+ },
129
+ ],
130
+ ])('should correctly parse chunks and strip out bolt artifacts (%#)', (input, expected) => {
131
+ runTest(input, expected);
132
  });
133
+ });
134
 
135
+ describe('valid artifacts with actions', () => {
136
+ it.each<[string | string[], ExpectedResult | string]>([
137
+ [
138
+ 'Before <boltArtifact title="Some title" id="artifact_1"><boltAction type="shell">npm install</boltAction></boltArtifact> After',
139
+ {
140
+ output: 'Before After',
141
+ callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 1, onActionClose: 1 },
142
+ },
143
+ ],
144
+ [
145
+ 'Before <boltArtifact title="Some title" id="artifact_1"><boltAction type="shell">npm install</boltAction><boltAction type="file" filePath="index.js">some content</boltAction></boltArtifact> After',
146
+ {
147
+ output: 'Before After',
148
+ callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 2, onActionClose: 2 },
149
+ },
150
+ ],
151
+ ])('should correctly parse chunks and strip out bolt artifacts (%#)', (input, expected) => {
152
+ runTest(input, expected);
153
+ });
154
+ });
155
+ });
156
 
157
+ function runTest(input: string | string[], outputOrExpectedResult: string | ExpectedResult) {
158
+ let expected: ExpectedResult;
159
 
160
+ if (typeof outputOrExpectedResult === 'string') {
161
+ expected = { output: outputOrExpectedResult };
162
+ } else {
163
+ expected = outputOrExpectedResult;
164
+ }
165
 
166
+ const callbacks = {
167
+ onArtifactOpen: vi.fn<ArtifactCallback>((data) => {
168
+ expect(data).toMatchSnapshot('onArtifactOpen');
169
+ }),
170
+ onArtifactClose: vi.fn<ArtifactCallback>((data) => {
171
+ expect(data).toMatchSnapshot('onArtifactClose');
172
+ }),
173
+ onActionOpen: vi.fn<ActionCallback>((data) => {
174
+ expect(data).toMatchSnapshot('onActionOpen');
175
+ }),
176
+ onActionClose: vi.fn<ActionCallback>((data) => {
177
+ expect(data).toMatchSnapshot('onActionClose');
178
+ }),
179
+ };
180
 
181
+ const parser = new StreamingMessageParser({
182
+ artifactElement: '',
183
+ callbacks,
184
  });
185
+
186
+ let message = '';
187
+
188
+ let result = '';
189
+
190
+ const chunks = Array.isArray(input) ? input : input.split('');
191
+
192
+ for (const chunk of chunks) {
193
+ message += chunk;
194
+
195
+ result += parser.parse('message_1', message);
196
+ }
197
+
198
+ for (const name in expected.callbacks) {
199
+ const callbackName = name;
200
+
201
+ expect(callbacks[callbackName as keyof typeof callbacks]).toHaveBeenCalledTimes(
202
+ expected.callbacks[callbackName as keyof typeof expected.callbacks] ?? 0,
203
+ );
204
+ }
205
+
206
+ expect(result).toEqual(expected.output);
207
+ }
packages/bolt/app/lib/runtime/message-parser.ts CHANGED
@@ -21,20 +21,20 @@ export interface ActionCallbackData {
21
  action: BoltAction;
22
  }
23
 
24
- type ArtifactOpenCallback = (data: ArtifactCallbackData) => void;
25
- type ArtifactCloseCallback = (data: ArtifactCallbackData) => void;
26
- type ActionCallback = (data: ActionCallbackData) => void;
27
-
28
- interface Callbacks {
29
- onArtifactOpen?: ArtifactOpenCallback;
30
- onArtifactClose?: ArtifactCloseCallback;
31
- onAction?: ActionCallback;
32
  }
33
 
34
  type ElementFactory = () => string;
35
 
36
- interface StreamingMessageParserOptions {
37
- callbacks?: Callbacks;
38
  artifactElement?: string | ElementFactory;
39
  }
40
 
@@ -95,10 +95,17 @@ export class StreamingMessageParser {
95
 
96
  currentAction.content = content;
97
 
98
- this._options.callbacks?.onAction?.({
99
  artifactId: currentArtifact.id,
100
  messageId,
101
- actionId: String(state.actionId++),
 
 
 
 
 
 
 
102
  action: currentAction as BoltAction,
103
  });
104
 
@@ -117,30 +124,16 @@ export class StreamingMessageParser {
117
  const actionEndIndex = input.indexOf('>', actionOpenIndex);
118
 
119
  if (actionEndIndex !== -1) {
120
- const actionTag = input.slice(actionOpenIndex, actionEndIndex + 1);
121
-
122
- const actionType = this.#extractAttribute(actionTag, 'type') as ActionType;
123
-
124
- const actionAttributes = {
125
- type: actionType,
126
- content: '',
127
- };
128
-
129
- if (actionType === 'file') {
130
- const filePath = this.#extractAttribute(actionTag, 'filePath') as string;
131
-
132
- if (!filePath) {
133
- logger.debug('File path not specified');
134
- }
135
-
136
- (actionAttributes as FileAction).filePath = filePath;
137
- } else if (actionType !== 'shell') {
138
- logger.warn(`Unknown action type '${actionType}'`);
139
- }
140
 
141
- state.currentAction = actionAttributes as FileAction | ShellAction;
142
 
143
- state.insideAction = true;
 
 
 
 
 
144
 
145
  i = actionEndIndex + 1;
146
  } else {
@@ -241,6 +234,31 @@ export class StreamingMessageParser {
241
  this.#messages.clear();
242
  }
243
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  #extractAttribute(tag: string, attributeName: string): string | undefined {
245
  const match = tag.match(new RegExp(`${attributeName}="([^"]*)"`, 'i'));
246
  return match ? match[1] : undefined;
 
21
  action: BoltAction;
22
  }
23
 
24
+ export type ArtifactCallback = (data: ArtifactCallbackData) => void;
25
+ export type ActionCallback = (data: ActionCallbackData) => void;
26
+
27
+ export interface ParserCallbacks {
28
+ onArtifactOpen?: ArtifactCallback;
29
+ onArtifactClose?: ArtifactCallback;
30
+ onActionOpen?: ActionCallback;
31
+ onActionClose?: ActionCallback;
32
  }
33
 
34
  type ElementFactory = () => string;
35
 
36
+ export interface StreamingMessageParserOptions {
37
+ callbacks?: ParserCallbacks;
38
  artifactElement?: string | ElementFactory;
39
  }
40
 
 
95
 
96
  currentAction.content = content;
97
 
98
+ this._options.callbacks?.onActionClose?.({
99
  artifactId: currentArtifact.id,
100
  messageId,
101
+
102
+ /**
103
+ * We decrement the id because it's been incremented already
104
+ * when `onActionOpen` was emitted to make sure the ids are
105
+ * the same.
106
+ */
107
+ actionId: String(state.actionId - 1),
108
+
109
  action: currentAction as BoltAction,
110
  });
111
 
 
124
  const actionEndIndex = input.indexOf('>', actionOpenIndex);
125
 
126
  if (actionEndIndex !== -1) {
127
+ state.insideAction = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
 
129
+ state.currentAction = this.#parseActionTag(input, actionOpenIndex, actionEndIndex);
130
 
131
+ this._options.callbacks?.onActionOpen?.({
132
+ artifactId: currentArtifact.id,
133
+ messageId,
134
+ actionId: String(state.actionId++),
135
+ action: state.currentAction as BoltAction,
136
+ });
137
 
138
  i = actionEndIndex + 1;
139
  } else {
 
234
  this.#messages.clear();
235
  }
236
 
237
+ #parseActionTag(input: string, actionOpenIndex: number, actionEndIndex: number) {
238
+ const actionTag = input.slice(actionOpenIndex, actionEndIndex + 1);
239
+
240
+ const actionType = this.#extractAttribute(actionTag, 'type') as ActionType;
241
+
242
+ const actionAttributes = {
243
+ type: actionType,
244
+ content: '',
245
+ };
246
+
247
+ if (actionType === 'file') {
248
+ const filePath = this.#extractAttribute(actionTag, 'filePath') as string;
249
+
250
+ if (!filePath) {
251
+ logger.debug('File path not specified');
252
+ }
253
+
254
+ (actionAttributes as FileAction).filePath = filePath;
255
+ } else if (actionType !== 'shell') {
256
+ logger.warn(`Unknown action type '${actionType}'`);
257
+ }
258
+
259
+ return actionAttributes as FileAction | ShellAction;
260
+ }
261
+
262
  #extractAttribute(tag: string, attributeName: string): string | undefined {
263
  const match = tag.match(new RegExp(`${attributeName}="([^"]*)"`, 'i'));
264
  return match ? match[1] : undefined;
packages/bolt/app/lib/stores/chat.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import { map } from 'nanostores';
2
+
3
+ export const chatStore = map({
4
+ aborted: false,
5
+ });
packages/bolt/app/lib/stores/workbench.ts CHANGED
@@ -4,25 +4,30 @@ import { unreachable } from '../../utils/unreachable';
4
  import { ActionRunner } from '../runtime/action-runner';
5
  import type { ActionCallbackData, ArtifactCallbackData } from '../runtime/message-parser';
6
  import { webcontainer } from '../webcontainer';
 
7
  import { PreviewsStore } from './previews';
8
 
9
- export type RunningState = BoltAction & {
 
 
10
  status: 'running' | 'complete' | 'pending' | 'aborted';
 
11
  abort?: () => void;
12
  };
13
 
14
- export type FailedState = BoltAction & {
15
- status: 'failed';
16
- error: string;
17
- abort?: () => void;
18
- };
19
 
20
- export type ActionState = RunningState | FailedState;
 
 
21
 
22
  export type ActionStateUpdate =
23
- | { status: 'running' | 'complete' | 'pending' | 'aborted'; abort?: () => void }
24
- | { status: 'failed'; error: string; abort?: () => void }
25
- | { abort?: () => void };
26
 
27
  export interface ArtifactState {
28
  title: string;
@@ -49,6 +54,16 @@ export class WorkbenchStore {
49
  this.showWorkbench.set(show);
50
  }
51
 
 
 
 
 
 
 
 
 
 
 
52
  addArtifact({ id, messageId, title }: ArtifactCallbackData) {
53
  const artifacts = this.artifacts.get();
54
  const artifactKey = getArtifactKey(id, messageId);
@@ -78,7 +93,7 @@ export class WorkbenchStore {
78
  this.artifacts.setKey(key, { ...artifact, ...state });
79
  }
80
 
81
- async runAction(data: ActionCallbackData) {
82
  const { artifactId, messageId, actionId } = data;
83
 
84
  const artifacts = this.artifacts.get();
@@ -96,33 +111,70 @@ export class WorkbenchStore {
96
  return;
97
  }
98
 
99
- artifact.actions.setKey(actionId, { ...data.action, status: 'pending' });
100
 
101
- artifact.currentActionPromise = artifact.currentActionPromise.then(async () => {
102
- try {
103
- let abortController: AbortController | undefined;
 
104
 
105
- if (data.action.type === 'shell') {
106
- abortController = new AbortController();
107
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
- let aborted = false;
 
 
 
 
 
 
 
 
 
 
 
110
 
111
- this.#updateAction(key, actionId, {
112
- status: 'running',
113
- abort: () => {
114
- aborted = true;
115
- abortController?.abort();
116
- },
117
- });
118
 
119
- await this.#actionRunner.runAction(data, abortController?.signal);
 
 
 
 
 
 
 
120
 
121
- this.#updateAction(key, actionId, { status: aborted ? 'aborted' : 'complete' });
 
 
 
 
 
 
 
 
122
  } catch (error) {
123
  this.#updateAction(key, actionId, { status: 'failed', error: 'Action failed' });
124
 
125
  throw error;
 
 
126
  }
127
  });
128
  }
 
4
  import { ActionRunner } from '../runtime/action-runner';
5
  import type { ActionCallbackData, ArtifactCallbackData } from '../runtime/message-parser';
6
  import { webcontainer } from '../webcontainer';
7
+ import { chatStore } from './chat';
8
  import { PreviewsStore } from './previews';
9
 
10
+ const MIN_SPINNER_TIME = 200;
11
+
12
+ export type BaseActionState = BoltAction & {
13
  status: 'running' | 'complete' | 'pending' | 'aborted';
14
+ executing: boolean;
15
  abort?: () => void;
16
  };
17
 
18
+ export type FailedActionState = BoltAction &
19
+ Omit<BaseActionState, 'status'> & {
20
+ status: 'failed';
21
+ error: string;
22
+ };
23
 
24
+ export type ActionState = BaseActionState | FailedActionState;
25
+
26
+ type BaseActionUpdate = Partial<Pick<BaseActionState, 'status' | 'executing' | 'abort'>>;
27
 
28
  export type ActionStateUpdate =
29
+ | BaseActionUpdate
30
+ | (Omit<BaseActionUpdate, 'status'> & { status: 'failed'; error: string });
 
31
 
32
  export interface ArtifactState {
33
  title: string;
 
54
  this.showWorkbench.set(show);
55
  }
56
 
57
+ abortAllActions() {
58
+ for (const [, artifact] of Object.entries(this.artifacts.get())) {
59
+ for (const [, action] of Object.entries(artifact.actions.get())) {
60
+ if (action.status === 'running') {
61
+ action.abort?.();
62
+ }
63
+ }
64
+ }
65
+ }
66
+
67
  addArtifact({ id, messageId, title }: ArtifactCallbackData) {
68
  const artifacts = this.artifacts.get();
69
  const artifactKey = getArtifactKey(id, messageId);
 
93
  this.artifacts.setKey(key, { ...artifact, ...state });
94
  }
95
 
96
+ async addAction(data: ActionCallbackData) {
97
  const { artifactId, messageId, actionId } = data;
98
 
99
  const artifacts = this.artifacts.get();
 
111
  return;
112
  }
113
 
114
+ artifact.actions.setKey(actionId, { ...data.action, status: 'pending', executing: false });
115
 
116
+ artifact.currentActionPromise.then(() => {
117
+ if (chatStore.get().aborted) {
118
+ return;
119
+ }
120
 
121
+ this.#updateAction(key, actionId, { status: 'running' });
122
+ });
123
+ }
124
+
125
+ async runAction(data: ActionCallbackData) {
126
+ const { artifactId, messageId, actionId } = data;
127
+
128
+ const artifacts = this.artifacts.get();
129
+ const key = getArtifactKey(artifactId, messageId);
130
+ const artifact = artifacts[key];
131
+
132
+ if (!artifact) {
133
+ unreachable('Artifact not found');
134
+ }
135
+
136
+ const actions = artifact.actions.get();
137
+ const action = actions[actionId];
138
 
139
+ if (!action) {
140
+ unreachable('Expected action to exist');
141
+ }
142
+
143
+ if (action.executing || action.status === 'complete' || action.status === 'failed' || action.status === 'aborted') {
144
+ return;
145
+ }
146
+
147
+ artifact.currentActionPromise = artifact.currentActionPromise.then(async () => {
148
+ if (chatStore.get().aborted) {
149
+ return;
150
+ }
151
 
152
+ const abortController = new AbortController();
 
 
 
 
 
 
153
 
154
+ this.#updateAction(key, actionId, {
155
+ status: 'running',
156
+ executing: true,
157
+ abort: () => {
158
+ abortController.abort();
159
+ this.#updateAction(key, actionId, { status: 'aborted' });
160
+ },
161
+ });
162
 
163
+ try {
164
+ await Promise.all([
165
+ this.#actionRunner.runAction(data, abortController.signal),
166
+ new Promise((resolve) => setTimeout(resolve, MIN_SPINNER_TIME)),
167
+ ]);
168
+
169
+ if (!abortController.signal.aborted) {
170
+ this.#updateAction(key, actionId, { status: 'complete' });
171
+ }
172
  } catch (error) {
173
  this.#updateAction(key, actionId, { status: 'failed', error: 'Action failed' });
174
 
175
  throw error;
176
+ } finally {
177
+ this.#updateAction(key, actionId, { executing: false });
178
  }
179
  });
180
  }