Skip to content

Commit 20de20c

Browse files
committed
fix: session fixes #12-#18
- #12: Start Application timeout race in action-runner.ts - #13: Terminal overflow containment (Markdown.module.scss + UserMessage.tsx) - #14: Token optimization 64% reduction via simplifyTemplateActions (stream-text.ts) - #15: Template install/start sequencing (selectStarterTemplate.ts) - #16: urlId uniqueness toast error - serialization lock (useChatHistory.ts) - #17: Create vs Edit action labels (Artifact.tsx) - #18: boltArtifact tag leak in file content (message-parser.ts + action-runner.ts)
1 parent 01d5cda commit 20de20c

9 files changed

Lines changed: 367 additions & 83 deletions

File tree

app/components/chat/Artifact.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -303,9 +303,13 @@ const ActionList = memo(({ actions }: ActionListProps) => {
303303
// File was directly written (staging disabled or auto-approved)
304304
diffStats = { linesAdded: action.content.split('\n').length, linesRemoved: 0 };
305305
fileContent = action.content;
306-
actionLabel = 'Create';
306+
307+
// Check if the file already existed in the workbench to show Edit vs Create
308+
const existingFile = workbenchStore.files.get()[`${WORK_DIR}/${filePath}`];
309+
actionLabel = existingFile ? 'Edit' : 'Create';
307310
} else {
308-
actionLabel = 'Create';
311+
const existingFile = workbenchStore.files.get()[`${WORK_DIR}/${filePath}`];
312+
actionLabel = existingFile ? 'Edit' : 'Create';
309313
}
310314
} else if (type === 'shell') {
311315
actionLabel = 'Run command';

app/components/chat/Markdown.module.scss

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ $code-font-size: 13px;
1010
.MarkdownContent {
1111
line-height: 1.6;
1212
color: var(--bolt-elements-textPrimary);
13+
min-width: 0;
14+
max-width: 100%;
15+
overflow: hidden;
16+
overflow-wrap: break-word;
17+
word-wrap: break-word;
1318

1419
> *:not(:last-child) {
1520
margin-block-end: 16px;
@@ -92,6 +97,8 @@ $code-font-size: 13px;
9297
pre {
9398
padding: 20px 16px;
9499
border-radius: 6px;
100+
max-width: 100%;
101+
overflow: hidden;
95102
}
96103

97104
pre:has(> code) {

app/components/chat/UserMessage.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export function UserMessage({ content, parts }: UserMessageProps) {
3232
const textContent = stripMetadata(textItem?.text || '');
3333

3434
return (
35-
<div className="overflow-hidden flex flex-col gap-3 items-end w-full">
35+
<div className="overflow-hidden flex flex-col gap-3 items-end w-full min-w-0">
3636
<div className="flex flex-row items-center gap-2 self-end">
3737
{profile?.avatar || profile?.username ? (
3838
<div className="flex items-center gap-2">
@@ -56,9 +56,9 @@ export function UserMessage({ content, parts }: UserMessageProps) {
5656
</div>
5757
)}
5858
</div>
59-
<div className="flex flex-col gap-3 max-w-[85%] ml-auto">
59+
<div className="flex flex-col gap-3 max-w-[85%] ml-auto overflow-hidden">
6060
{textContent && (
61-
<div className="text-bolt-elements-textPrimary text-sm leading-relaxed">
61+
<div className="text-bolt-elements-textPrimary text-sm leading-relaxed min-w-0">
6262
<Markdown html>{textContent}</Markdown>
6363
</div>
6464
)}
@@ -85,7 +85,7 @@ export function UserMessage({ content, parts }: UserMessageProps) {
8585
const textContent = stripMetadata(content);
8686

8787
return (
88-
<div className="flex flex-col items-end gap-3 w-full">
88+
<div className="flex flex-col items-end gap-3 w-full min-w-0">
8989
<div className="flex items-center gap-2">
9090
{profile?.avatar ? (
9191
<>
@@ -107,7 +107,7 @@ export function UserMessage({ content, parts }: UserMessageProps) {
107107
</>
108108
)}
109109
</div>
110-
<div className="max-w-[85%] ml-auto">
110+
<div className="max-w-[85%] ml-auto overflow-hidden">
111111
{images.length > 0 && (
112112
<div className="flex flex-wrap gap-2 mb-3">
113113
{images.map((item, index) => (
@@ -121,7 +121,7 @@ export function UserMessage({ content, parts }: UserMessageProps) {
121121
))}
122122
</div>
123123
)}
124-
<div className="text-bolt-elements-textPrimary text-sm leading-relaxed">
124+
<div className="text-bolt-elements-textPrimary text-sm leading-relaxed min-w-0">
125125
<Markdown html>{textContent}</Markdown>
126126
</div>
127127
</div>

app/lib/.server/llm/stream-text.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,76 @@ function getCompletionTokenLimit(modelDetails: ModelInfo): number {
5252
return Math.min(MAX_TOKENS, 16384);
5353
}
5454

55+
/*
56+
* Essential files whose content the LLM needs to see in the template message.
57+
* Everything else (shadcn components, etc.) gets replaced with "..." to save tokens.
58+
*/
59+
const ESSENTIAL_FILE_PATTERNS = [
60+
'package.json',
61+
'vite.config.ts',
62+
'vite.config.js',
63+
'tsconfig.json',
64+
'tsconfig.app.json',
65+
'tsconfig.node.json',
66+
'tailwind.config.js',
67+
'tailwind.config.ts',
68+
'postcss.config.js',
69+
'postcss.config.mjs',
70+
'components.json',
71+
'index.html',
72+
'src/App.tsx',
73+
'src/App.jsx',
74+
'src/main.tsx',
75+
'src/main.jsx',
76+
'src/index.tsx',
77+
'src/index.jsx',
78+
'src/index.css',
79+
'src/App.css',
80+
'src/lib/utils.ts',
81+
'src/vite-env.d.ts',
82+
'app/root.tsx',
83+
'app/entry.client.tsx',
84+
'app/entry.server.tsx',
85+
'next.config.js',
86+
'next.config.ts',
87+
'next.config.mjs',
88+
];
89+
90+
function isEssentialFile(filePath: string): boolean {
91+
return ESSENTIAL_FILE_PATTERNS.some((pattern) => filePath === pattern || filePath.endsWith(`/${pattern}`));
92+
}
93+
94+
/**
95+
* Simplify non-essential boltAction file contents to "..." to reduce token usage.
96+
* Essential config/entry files keep their full content so the LLM understands the project structure.
97+
* Lock files are stripped entirely (they're huge and the LLM never needs them).
98+
*/
99+
function simplifyTemplateActions(text: string): string {
100+
/* Strip lock files entirely — they can be 6000+ lines (~25K tokens) */
101+
let result = text.replace(
102+
/<boltAction type="file" filePath="(?:package-lock\.json|yarn\.lock|pnpm-lock\.yaml)">[\s\S]*?<\/boltAction>/g,
103+
'',
104+
);
105+
106+
/* Replace non-essential file contents with "..." */
107+
result = result.replace(
108+
/(<boltAction[^>]*type="file"[^>]*filePath="([^"]+)"[^>]*>)([\s\S]*?)(<\/boltAction>)/g,
109+
(match, openTag: string, filePath: string, _content: string, closeTag: string) => {
110+
if (isEssentialFile(filePath)) {
111+
return match;
112+
}
113+
114+
return `${openTag}...${closeTag}`;
115+
},
116+
);
117+
118+
return result;
119+
}
120+
55121
function sanitizeText(text: string): string {
56122
let sanitized = text.replace(/<div class=\\"__boltThought__\\">.*?<\/div>/s, '');
57123
sanitized = sanitized.replace(/<think>.*?<\/think>/s, '');
58-
sanitized = sanitized.replace(/<boltAction type="file" filePath="package-lock\.json">[\s\S]*?<\/boltAction>/g, '');
124+
sanitized = simplifyTemplateActions(sanitized);
59125

60126
return sanitized.trim();
61127
}

app/lib/persistence/useChatHistory.ts

Lines changed: 75 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ export function useChatHistory() {
6363
// Track last snapshot parameters so debounced file-change saves use the same message ID
6464
const lastSnapshotParamsRef = useRef<{ chatIdx: string; chatSummary?: string } | null>(null);
6565

66+
/* Serialization lock to prevent concurrent storeMessageHistory calls which cause 'urlId' uniqueness constraint errors in IndexedDB */
67+
const isStoringRef = useRef(false);
68+
6669
useEffect(() => {
6770
if (!db) {
6871
setReady(true);
@@ -358,80 +361,94 @@ export function useChatHistory() {
358361
return;
359362
}
360363

361-
const { firstArtifact } = workbenchStore;
362-
messages = messages.filter((m) => !m.annotations?.includes('no-store'));
363-
364364
/*
365-
* Ensure chatId is set on the very first message.
366-
* Always use a sequential numeric ID from getNextId() for consistency.
365+
* Skip if another storeMessageHistory call is already in-flight.
366+
* The 50ms sampler will try again with the latest messages.
367367
*/
368-
if (initialMessages.length === 0 && !chatId.get()) {
369-
const nextId = await getNextId(db);
370-
chatId.set(nextId);
371-
versionsStore.setDBContext(db, nextId);
368+
if (isStoringRef.current) {
369+
return;
372370
}
373371

374-
/*
375-
* Ensure urlId is set once and never changes.
376-
* Derive it from the numeric chatId so URLs are always consistent
377-
* (e.g. /chat/1, /chat/2) regardless of whether artifacts exist.
378-
* Previously, artifact-based IDs like "2-1771470328283-0" were used
379-
* when the AI generated artifacts, causing inconsistent URLs.
380-
*/
381-
let resolvedUrlId = urlId;
372+
isStoringRef.current = true;
382373

383-
if (!resolvedUrlId) {
384-
const id = chatId.get()!;
385-
resolvedUrlId = await getUrlId(db, id);
386-
setUrlId(resolvedUrlId);
387-
navigateChat(resolvedUrlId);
388-
}
374+
try {
375+
const { firstArtifact } = workbenchStore;
376+
messages = messages.filter((m) => !m.annotations?.includes('no-store'));
377+
378+
/*
379+
* Ensure chatId is set on the very first message.
380+
* Always use a sequential numeric ID from getNextId() for consistency.
381+
*/
382+
if (initialMessages.length === 0 && !chatId.get()) {
383+
const nextId = await getNextId(db);
384+
chatId.set(nextId);
385+
versionsStore.setDBContext(db, nextId);
386+
}
387+
388+
/*
389+
* Ensure urlId is set once and never changes.
390+
* Derive it from the numeric chatId so URLs are always consistent
391+
* (e.g. /chat/1, /chat/2) regardless of whether artifacts exist.
392+
* Previously, artifact-based IDs like "2-1771470328283-0" were used
393+
* when the AI generated artifacts, causing inconsistent URLs.
394+
*/
395+
let resolvedUrlId = urlId;
396+
397+
if (!resolvedUrlId) {
398+
const id = chatId.get()!;
399+
resolvedUrlId = await getUrlId(db, id);
400+
setUrlId(resolvedUrlId);
401+
navigateChat(resolvedUrlId);
402+
}
389403

390-
let chatSummary: string | undefined = undefined;
391-
const lastMessage = messages[messages.length - 1];
404+
let chatSummary: string | undefined = undefined;
405+
const lastMessage = messages[messages.length - 1];
392406

393-
if (lastMessage.role === 'assistant') {
394-
const annotations = lastMessage.annotations as JSONValue[];
395-
const filteredAnnotations = (annotations?.filter(
396-
(annotation: JSONValue) =>
397-
annotation && typeof annotation === 'object' && Object.keys(annotation).includes('type'),
398-
) || []) as (Record<string, unknown> & { type: string })[];
407+
if (lastMessage.role === 'assistant') {
408+
const annotations = lastMessage.annotations as JSONValue[];
409+
const filteredAnnotations = (annotations?.filter(
410+
(annotation: JSONValue) =>
411+
annotation && typeof annotation === 'object' && Object.keys(annotation).includes('type'),
412+
) || []) as (Record<string, unknown> & { type: string })[];
399413

400-
if (filteredAnnotations.find((annotation) => annotation.type === 'chatSummary')) {
401-
chatSummary = filteredAnnotations.find((annotation) => annotation.type === 'chatSummary')?.summary as
402-
| string
403-
| undefined;
414+
if (filteredAnnotations.find((annotation) => annotation.type === 'chatSummary')) {
415+
chatSummary = filteredAnnotations.find((annotation) => annotation.type === 'chatSummary')?.summary as
416+
| string
417+
| undefined;
418+
}
404419
}
405-
}
406420

407-
// Save params so debounced file-change subscriber can re-save with updated files
408-
lastSnapshotParamsRef.current = { chatIdx: messages[messages.length - 1].id, chatSummary };
421+
// Save params so debounced file-change subscriber can re-save with updated files
422+
lastSnapshotParamsRef.current = { chatIdx: messages[messages.length - 1].id, chatSummary };
409423

410-
takeSnapshot(messages[messages.length - 1].id, workbenchStore.files.get(), resolvedUrlId, chatSummary);
424+
takeSnapshot(messages[messages.length - 1].id, workbenchStore.files.get(), resolvedUrlId, chatSummary);
411425

412-
if (!description.get() && firstArtifact?.title) {
413-
description.set(firstArtifact?.title);
414-
}
426+
if (!description.get() && firstArtifact?.title) {
427+
description.set(firstArtifact?.title);
428+
}
415429

416-
// Ensure chatId.get() is used for the final setMessages call
417-
const finalChatId = chatId.get();
430+
// Ensure chatId.get() is used for the final setMessages call
431+
const finalChatId = chatId.get();
418432

419-
if (!finalChatId) {
420-
logger.error('Cannot save messages, chat ID is not set.');
421-
toast.error('Failed to save chat messages: Chat ID missing.');
433+
if (!finalChatId) {
434+
logger.error('Cannot save messages, chat ID is not set.');
435+
toast.error('Failed to save chat messages: Chat ID missing.');
422436

423-
return;
424-
}
437+
return;
438+
}
425439

426-
await setMessages(
427-
db,
428-
finalChatId,
429-
[...archivedMessages, ...messages],
430-
resolvedUrlId, // Always use the resolved urlId, not stale useState
431-
description.get(),
432-
undefined,
433-
chatMetadata.get(),
434-
);
440+
await setMessages(
441+
db,
442+
finalChatId,
443+
[...archivedMessages, ...messages],
444+
resolvedUrlId, // Always use the resolved urlId, not stale useState
445+
description.get(),
446+
undefined,
447+
chatMetadata.get(),
448+
);
449+
} finally {
450+
isStoringRef.current = false;
451+
}
435452
},
436453
duplicateCurrentChat: async (listItemId: string) => {
437454
if (!db || (!mixedId && !listItemId)) {

app/lib/runtime/action-runner.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -454,10 +454,32 @@ export class ActionRunner {
454454
unreachable('Shell terminal not found');
455455
}
456456

457-
const resp = await shell.executeCommand(this.runnerId.get(), action.content, () => {
457+
/*
458+
* Dev servers (npm run dev, vite, etc.) run indefinitely and never exit,
459+
* so shell.executeCommand() would never resolve. We race the execution
460+
* against a timeout — if the command hasn't exited after the timeout,
461+
* the server started successfully and we mark the action complete.
462+
* If the command exits quickly (e.g. port conflict), we catch the error.
463+
*/
464+
const SERVER_READY_TIMEOUT = 5000;
465+
466+
const execPromise = shell.executeCommand(this.runnerId.get(), action.content, () => {
458467
logger.debug(`[${action.type}]:Aborting Action\n\n`, action);
459468
action.abort();
460469
});
470+
471+
const timeoutPromise = new Promise<'server-running'>((resolve) =>
472+
setTimeout(() => resolve('server-running'), SERVER_READY_TIMEOUT),
473+
);
474+
475+
const result = await Promise.race([execPromise, timeoutPromise]);
476+
477+
if (result === 'server-running') {
478+
logger.debug(`${action.type}: Dev server is running (command did not exit within ${SERVER_READY_TIMEOUT}ms)`);
479+
return undefined;
480+
}
481+
482+
const resp = result;
461483
logger.debug(`${action.type} Shell Response: [exit code:${resp?.exitCode}]`);
462484

463485
if (resp?.exitCode != 0) {
@@ -550,6 +572,13 @@ export class ActionRunner {
550572
try {
551573
let contentToWrite = action.content;
552574

575+
/*
576+
* Safety net: Strip any leaked bolt XML tags from file content.
577+
* This can happen when the LLM omits closing tags and the parser's
578+
* streaming path accidentally includes artifact/action markup.
579+
*/
580+
contentToWrite = contentToWrite.replace(/<\/?boltArtifact[^>]*>/g, '').replace(/<\/?boltAction[^>]*>/g, '');
581+
553582
/*
554583
* Safety net: When package.json is being overwritten, merge dependencies
555584
* from the existing file to prevent the LLM from accidentally dropping

0 commit comments

Comments
 (0)