Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: enhanced Terminal Error Handling and Alert System #797

Merged
merged 11 commits into from
Dec 23, 2024
2 changes: 1 addition & 1 deletion app/commit.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{ "commit": "a9309161e95a8ed015f2f71b622fb63afdb74877", "version": "0.0.3" }
{ "commit": "b25b8b98f9c3c2b009802c2608e3e6a5a1f81569" }
431 changes: 229 additions & 202 deletions app/components/chat/BaseChat.tsx

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions app/components/chat/Chat.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export const ChatImpl = memo(
const [imageDataList, setImageDataList] = useState<string[]>([]); // Move here
const [searchParams, setSearchParams] = useSearchParams();
const files = useStore(workbenchStore.files);
const actionAlert = useStore(workbenchStore.alert);
const { activeProviders, promptId } = useSettings();

const [model, setModel] = useState(() => {
Expand Down Expand Up @@ -387,6 +388,8 @@ export const ChatImpl = memo(
setUploadedFiles={setUploadedFiles}
imageDataList={imageDataList}
setImageDataList={setImageDataList}
actionAlert={actionAlert}
clearAlert={() => workbenchStore.clearAlert()}
/>
);
},
Expand Down
102 changes: 102 additions & 0 deletions app/components/chat/ChatAlert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { AnimatePresence, motion } from 'framer-motion';
import type { ActionAlert } from '~/types/actions';
import { classNames } from '~/utils/classNames';

interface Props {
alert: ActionAlert;
clearAlert: () => void;
postMessage: (message: string) => void;
}

export default function ChatAlert({ alert, clearAlert, postMessage }: Props) {
const { description, content } = alert;

return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className={`rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 p-4`}
>
<div className="flex items-start">
{/* Icon */}
<motion.div
className="flex-shrink-0"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2 }}
>
<div className={`i-ph:warning-duotone text-xl text-bolt-elements-button-danger-text`}></div>
</motion.div>
{/* Content */}
<div className="ml-3 flex-1">
<motion.h3
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.1 }}
className={`text-sm font-medium text-bolt-elements-textPrimary`}
>
{/* {title} */}
Opps There is an error
</motion.h3>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className={`mt-2 text-sm text-bolt-elements-textSecondary`}
>
<p>
We encountered an error while running terminal commands. Would you like Bolt to analyze and help resolve
this issue?
</p>
{description && (
<div className="text-xs text-bolt-elements-textSecondary p-2 bg-bolt-elements-background-depth-3 rounded mt-4 mb-4">
Error: {description}
</div>
)}
</motion.div>

{/* Actions */}
<motion.div
className="mt-4"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<div className={classNames(' flex gap-2')}>
<button
onClick={() => postMessage(`*Fix this error on terminal* \n\`\`\`sh\n${content}\n\`\`\`\n`)}
className={classNames(
`px-2 py-1.5 rounded-md text-sm font-medium`,
'bg-bolt-elements-button-primary-background',
'hover:bg-bolt-elements-button-primary-backgroundHover',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-bolt-elements-button-danger-background',
'text-bolt-elements-button-primary-text',
'flex items-center gap-1.5',
)}
>
<div className="i-ph:chat-circle-duotone"></div>
Ask Bolt
</button>
<button
onClick={clearAlert}
className={classNames(
`px-2 py-1.5 rounded-md text-sm font-medium`,
'bg-bolt-elements-button-secondary-background',
'hover:bg-bolt-elements-button-secondary-backgroundHover',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-bolt-elements-button-secondary-background',
'text-bolt-elements-button-secondary-text',
)}
>
Dismiss
</button>
</div>
</motion.div>
</div>
</div>
</motion.div>
</AnimatePresence>
);
}
1 change: 1 addition & 0 deletions app/lib/hooks/useEditChatDescription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export function useEditChatDescription({
}

const lengthValid = trimmedDesc.length > 0 && trimmedDesc.length <= 100;

// Allow letters, numbers, spaces, and common punctuation but exclude characters that could cause issues
const characterValid = /^[a-zA-Z0-9\s\-_.,!?()[\]{}'"]+$/.test(trimmedDesc);

Expand Down
88 changes: 81 additions & 7 deletions app/lib/runtime/action-runner.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { WebContainer } from '@webcontainer/api';
import { atom, map, type MapStore } from 'nanostores';
import * as nodePath from 'node:path';
import type { BoltAction } from '~/types/actions';
import type { ActionAlert, BoltAction } from '~/types/actions';
import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable';
import type { ActionCallbackData } from './message-parser';
Expand Down Expand Up @@ -34,16 +34,51 @@ export type ActionStateUpdate =

type ActionsMap = MapStore<Record<string, ActionState>>;

class ActionCommandError extends Error {
readonly _output: string;
readonly _header: string;

constructor(message: string, output: string) {
// Create a formatted message that includes both the error message and output
const formattedMessage = `Failed To Execute Shell Command: ${message}\n\nOutput:\n${output}`;
super(formattedMessage);

// Set the output separately so it can be accessed programmatically
this._header = message;
this._output = output;

// Maintain proper prototype chain
Object.setPrototypeOf(this, ActionCommandError.prototype);

// Set the name of the error for better debugging
this.name = 'ActionCommandError';
}

// Optional: Add a method to get just the terminal output
get output() {
return this._output;
}
get header() {
return this._header;
}
}

export class ActionRunner {
#webcontainer: Promise<WebContainer>;
#currentExecutionPromise: Promise<void> = Promise.resolve();
#shellTerminal: () => BoltShell;
runnerId = atom<string>(`${Date.now()}`);
actions: ActionsMap = map({});
onAlert?: (alert: ActionAlert) => void;

constructor(webcontainerPromise: Promise<WebContainer>, getShellTerminal: () => BoltShell) {
constructor(
webcontainerPromise: Promise<WebContainer>,
getShellTerminal: () => BoltShell,
onAlert?: (alert: ActionAlert) => void,
) {
this.#webcontainer = webcontainerPromise;
this.#shellTerminal = getShellTerminal;
this.onAlert = onAlert;
}

addAction(data: ActionCallbackData) {
Expand Down Expand Up @@ -126,7 +161,25 @@ export class ActionRunner {

this.#runStartAction(action)
.then(() => this.#updateAction(actionId, { status: 'complete' }))
.catch(() => this.#updateAction(actionId, { status: 'failed', error: 'Action failed' }));
.catch((err: Error) => {
if (action.abortSignal.aborted) {
return;
}

this.#updateAction(actionId, { status: 'failed', error: 'Action failed' });
logger.error(`[${action.type}]:Action failed\n\n`, err);

if (!(err instanceof ActionCommandError)) {
return;
}

this.onAlert?.({
type: 'error',
title: 'Dev Server Failed',
description: err.header,
content: err.output,
});
});

/*
* adding a delay to avoid any race condition between 2 start actions
Expand All @@ -142,9 +195,24 @@ export class ActionRunner {
status: isStreaming ? 'running' : action.abortSignal.aborted ? 'aborted' : 'complete',
});
} catch (error) {
if (action.abortSignal.aborted) {
return;
}

this.#updateAction(actionId, { status: 'failed', error: 'Action failed' });
logger.error(`[${action.type}]:Action failed\n\n`, error);

if (!(error instanceof ActionCommandError)) {
return;
}

this.onAlert?.({
type: 'error',
title: 'Dev Server Failed',
description: error.header,
content: error.output,
});

// re-throw the error to be caught in the promise chain
throw error;
}
Expand All @@ -162,11 +230,14 @@ export class ActionRunner {
unreachable('Shell terminal not found');
}

const resp = await shell.executeCommand(this.runnerId.get(), action.content);
const resp = await shell.executeCommand(this.runnerId.get(), action.content, () => {
logger.debug(`[${action.type}]:Aborting Action\n\n`, action);
action.abort();
});
logger.debug(`${action.type} Shell Response: [exit code:${resp?.exitCode}]`);

if (resp?.exitCode != 0) {
throw new Error('Failed To Execute Shell Command');
throw new ActionCommandError(`Failed To Execute Shell Command`, resp?.output || 'No Output Available');
}
}

Expand All @@ -186,11 +257,14 @@ export class ActionRunner {
unreachable('Shell terminal not found');
}

const resp = await shell.executeCommand(this.runnerId.get(), action.content);
const resp = await shell.executeCommand(this.runnerId.get(), action.content, () => {
logger.debug(`[${action.type}]:Aborting Action\n\n`, action);
action.abort();
});
logger.debug(`${action.type} Shell Response: [exit code:${resp?.exitCode}]`);

if (resp?.exitCode != 0) {
throw new Error('Failed To Start Application');
throw new ActionCommandError('Failed To Start Application', resp?.output || 'No Output Available');
}

return resp;
Expand Down
16 changes: 15 additions & 1 deletion app/lib/stores/workbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { extractRelativePath } from '~/utils/diff';
import { description } from '~/lib/persistence';
import Cookies from 'js-cookie';
import { createSampler } from '~/utils/sampler';
import type { ActionAlert } from '~/types/actions';

export interface ArtifactState {
id: string;
Expand All @@ -43,6 +44,8 @@ export class WorkbenchStore {
showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false);
currentView: WritableAtom<WorkbenchViewType> = import.meta.hot?.data.currentView ?? atom('code');
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
actionAlert: WritableAtom<ActionAlert | undefined> =
import.meta.hot?.data.unsavedFiles ?? atom<ActionAlert | undefined>(undefined);
modifiedFiles = new Set<string>();
artifactIdList: string[] = [];
#globalExecutionQueue = Promise.resolve();
Expand All @@ -52,6 +55,7 @@ export class WorkbenchStore {
import.meta.hot.data.unsavedFiles = this.unsavedFiles;
import.meta.hot.data.showWorkbench = this.showWorkbench;
import.meta.hot.data.currentView = this.currentView;
import.meta.hot.data.actionAlert = this.actionAlert;
}
}

Expand Down Expand Up @@ -89,6 +93,12 @@ export class WorkbenchStore {
get boltTerminal() {
return this.#terminalStore.boltTerminal;
}
get alert() {
return this.actionAlert;
}
clearAlert() {
this.actionAlert.set(undefined);
}

toggleTerminal(value?: boolean) {
this.#terminalStore.toggleTerminal(value);
Expand Down Expand Up @@ -249,7 +259,11 @@ export class WorkbenchStore {
title,
closed: false,
type,
runner: new ActionRunner(webcontainer, () => this.boltTerminal),
runner: new ActionRunner(
webcontainer,
() => this.boltTerminal,
(alert) => this.actionAlert.set(alert),
),
});
}

Expand Down
7 changes: 7 additions & 0 deletions app/types/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,10 @@ export interface StartAction extends BaseAction {
export type BoltAction = FileAction | ShellAction | StartAction;

export type BoltActionData = BoltAction | BaseAction;

export interface ActionAlert {
type: string;
title: string;
description: string;
content: string;
}
Loading
Loading