Skip to content

Commit ceced60

Browse files
committed
feat/fix: claude pasted content attachment extraction fix
1 parent 33686c7 commit ceced60

1 file changed

Lines changed: 216 additions & 14 deletions

File tree

src/modules/claude/chat-content.ts

Lines changed: 216 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,160 @@ function cleanupCodeBlocks(content: string): string {
1111
return content.replace(/([a-zA-Z0-9_+-]+)\s*```\1\b/g, '```$1');
1212
}
1313

14+
/**
15+
* Extract pasted content from Claude's pasted document side panel
16+
* @param pastedBlock - The pasted document element to click
17+
* @returns The extracted content with title and index, or null if extraction failed
18+
*/
19+
async function extractClaudePastedContent(pastedBlock: Element): Promise<string | null> {
20+
const MAX_RETRIES = 3;
21+
const BASE_WAIT_TIME = 1500;
22+
23+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
24+
try {
25+
const pastedTitle = 'Pasted Content';
26+
// click the pasted block to open the side panel
27+
(pastedBlock as HTMLElement).click();
28+
29+
// wait for the side panel to open and load content
30+
const waitTime = BASE_WAIT_TIME + attempt * 500;
31+
await new Promise((resolve) => setTimeout(resolve, waitTime));
32+
33+
// find the content in the side panel
34+
const contentElement = document.querySelector('.whitespace-pre-wrap.break-all.text-xs');
35+
if (!contentElement) {
36+
console.error('Could not find pasted content element');
37+
// try to close any open panel
38+
const closeButtons = document.querySelectorAll(
39+
'button[aria-label="Close"], button svg path[d*="15.8536"]'
40+
);
41+
if (closeButtons.length > 0) {
42+
const closeBtn = closeButtons[0].closest('button') as HTMLButtonElement;
43+
if (closeBtn) closeBtn.click();
44+
}
45+
return null;
46+
}
47+
48+
// extract the content text
49+
const pastedContent = contentElement.textContent || '';
50+
51+
if (pastedContent) {
52+
// close the pasted panel
53+
const closeButton = document.querySelector(
54+
'button[data-testid="close-file-preview"]'
55+
) as HTMLButtonElement;
56+
if (closeButton) {
57+
closeButton.click();
58+
await new Promise((resolve) => setTimeout(resolve, 200));
59+
}
60+
61+
// return formatted with title and index
62+
return `${pastedTitle}\n\n\`\`\`\n${pastedContent}\n\`\`\``;
63+
} else {
64+
console.warn(
65+
`Pasted content extraction attempt ${attempt + 1}: No content detected, retrying...`
66+
);
67+
// close panel and retry
68+
const closeButton = document.querySelector(
69+
'button[data-testid="close-file-preview"]'
70+
) as HTMLButtonElement;
71+
if (closeButton) {
72+
closeButton.click();
73+
await new Promise((resolve) => setTimeout(resolve, 200));
74+
}
75+
continue;
76+
}
77+
} catch (error) {
78+
console.error(`Pasted content extraction attempt ${attempt + 1} failed:`, error);
79+
if (attempt === MAX_RETRIES - 1) {
80+
console.error('All pasted content extraction attempts failed');
81+
return null;
82+
}
83+
// wait before retry
84+
await new Promise((resolve) => setTimeout(resolve, 500));
85+
}
86+
}
87+
88+
console.error('All pasted content extraction attempts failed');
89+
return null;
90+
}
91+
92+
/**
93+
* Extract all pasted content from a Claude user message container
94+
* @param messageContainer - The parent container of the user message
95+
* @returns Array of extracted pasted content blocks
96+
*/
97+
export async function extractAllClaudePastedContent(messageContainer: Element): Promise<string[]> {
98+
const pastedContents: string[] = [];
99+
let extractionErrors = 0;
100+
101+
try {
102+
// find all pasted document blocks in this message
103+
// look for elements with "pasted" badge
104+
const pastedBlocks: Element[] = [];
105+
106+
// First, look for regular file thumbnails with pasted badges
107+
const fileThumbnails = messageContainer.querySelectorAll('div[data-testid="file-thumbnail"]');
108+
fileThumbnails.forEach((thumbnail) => {
109+
const flexCol = thumbnail.querySelector('.flex-col');
110+
if (flexCol) {
111+
const badgeElement = flexCol.querySelector('.text-text-300');
112+
if (badgeElement && badgeElement.textContent?.toLowerCase().includes('pasted')) {
113+
pastedBlocks.push(flexCol);
114+
}
115+
}
116+
});
117+
118+
// Also look for pasted documents that might be direct children (for pasted-only messages)
119+
const directPastedElements = messageContainer.querySelectorAll('.flex-col .text-text-300');
120+
directPastedElements.forEach((badgeElement) => {
121+
if (badgeElement.textContent?.toLowerCase().includes('pasted')) {
122+
const flexCol = badgeElement.closest('.flex-col');
123+
if (flexCol && !pastedBlocks.includes(flexCol)) {
124+
pastedBlocks.push(flexCol);
125+
}
126+
}
127+
});
128+
129+
console.log(`Found ${pastedBlocks.length} pasted content blocks in message container`);
130+
131+
for (let i = 0; i < pastedBlocks.length; i++) {
132+
const pastedBlock = pastedBlocks[i];
133+
console.log(`Extracting pasted content ${i + 1}/${pastedBlocks.length}`);
134+
135+
try {
136+
const pastedContent = await extractClaudePastedContent(pastedBlock);
137+
if (pastedContent) {
138+
pastedContents.push(pastedContent);
139+
console.log(`Successfully extracted pasted content ${i + 1}`);
140+
} else {
141+
extractionErrors++;
142+
console.warn(`Failed to extract pasted content ${i + 1}: No content returned`);
143+
}
144+
} catch (error) {
145+
extractionErrors++;
146+
console.error(`Error extracting pasted content ${i + 1}:`, error);
147+
}
148+
149+
// small delay between pasted contents to prevent overwhelming the UI
150+
await new Promise((resolve) => setTimeout(resolve, 300));
151+
}
152+
153+
if (extractionErrors > 0) {
154+
console.warn(
155+
`Pasted content extraction completed with ${extractionErrors} errors out of ${pastedBlocks.length} total pasted contents`
156+
);
157+
} else {
158+
console.log(`Successfully extracted all ${pastedContents.length} pasted contents`);
159+
}
160+
161+
return pastedContents;
162+
} catch (error) {
163+
console.error('Critical error in extractAllClaudePastedContent:', error);
164+
return pastedContents; // return whatever we managed to extract
165+
}
166+
}
167+
14168
export async function getClaudePastedContent(div: Element): Promise<string | null> {
15169
// find all file and image elements
16170
const fileElements = div.querySelectorAll(
@@ -69,9 +223,21 @@ export const getClaudeChatContent = async () => {
69223
// claude.ai
70224
// user messages have data-testid="user-message"
71225
// assistant messages have class="font-claude-response"
226+
// also look for pasted-only messages (no data-testid but contain pasted documents)
72227
const userMessages = document.querySelectorAll('[data-testid="user-message"]');
73228
const assistantMessages = document.querySelectorAll('div.font-claude-response');
74229

230+
// find pasted-only messages (messages that contain pasted documents but no data-testid)
231+
const pastedOnlyMessages: Element[] = [];
232+
const messageGroups = document.querySelectorAll('div[data-test-render-count]');
233+
234+
messageGroups.forEach((group) => {
235+
const badgeElement = group.querySelector('.text-text-300');
236+
if (badgeElement && badgeElement.textContent?.toLowerCase().includes('pasted')) {
237+
pastedOnlyMessages.push(group);
238+
}
239+
});
240+
75241
let failedClaudeMessages = 0;
76242
const claudeMessages: Array<Message> = [];
77243

@@ -91,6 +257,13 @@ export const getClaudeChatContent = async () => {
91257
allMessages.push({ element: el, role: 'assistant', position });
92258
});
93259

260+
// add pasted-only messages as user messages
261+
pastedOnlyMessages.forEach((el) => {
262+
const rect = el.getBoundingClientRect();
263+
const position = rect.top + window.scrollY;
264+
allMessages.push({ element: el, role: 'user', position });
265+
});
266+
94267
// sort by position (top to bottom)
95268
allMessages.sort((a, b) => a.position - b.position);
96269

@@ -132,24 +305,53 @@ export const getClaudeChatContent = async () => {
132305
// for user messages, handle regular text, code blocks, and pasted content
133306
const messageParts: string[] = [];
134307

135-
// look for pasted/attached content (images, files)
136-
// find the parent message container and look for thumbnails
137-
const messageContainer = div.closest('[data-test-render-count]');
138-
const thumbnailContainer = messageContainer?.querySelector('div.group\\/thumbnail')
139-
?.parentElement?.parentElement as HTMLElement;
308+
// check if this is a pasted-only message (no data-testid attribute)
309+
const isPastedOnlyMessage = !div.hasAttribute('data-testid');
140310

141-
if (thumbnailContainer) {
142-
const pastedContent = await getClaudePastedContent(thumbnailContainer);
143-
if (pastedContent) {
144-
messageParts.push(pastedContent);
311+
if (isPastedOnlyMessage) {
312+
// this is a pasted-only message, extract pasted content directly from the message element
313+
const pastedContents = await extractAllClaudePastedContent(div);
314+
if (pastedContents.length > 0) {
315+
// format pasted content with indices
316+
pastedContents.forEach((pastedContent, index) => {
317+
messageParts.push(`Pasted Content #${index + 1}:\n\n${pastedContent}`);
318+
});
145319
await new Promise((resolve) => setTimeout(resolve, 100));
146320
}
147-
}
321+
} else {
322+
// this is a regular user message, handle both pasted content and text
323+
// look for pasted/attached content (images, files)
324+
// find the parent message container and look for thumbnails
325+
const messageContainer = div.closest('[data-test-render-count]');
326+
327+
if (messageContainer) {
328+
// extract pasted content from pasted document blocks
329+
const pastedContents = await extractAllClaudePastedContent(messageContainer);
330+
if (pastedContents.length > 0) {
331+
// format pasted content with indices
332+
pastedContents.forEach((pastedContent, index) => {
333+
messageParts.push(`Pasted Content #${index + 1}:\n\n${pastedContent}`);
334+
});
335+
await new Promise((resolve) => setTimeout(resolve, 100));
336+
}
148337

149-
// extract text content from user message
150-
const textContent = await extractFormattedText(div);
151-
if (textContent) {
152-
messageParts.push(textContent);
338+
// also handle regular file thumbnails (non-pasted)
339+
const thumbnailContainer = messageContainer?.querySelector('div.group\\/thumbnail')
340+
?.parentElement?.parentElement as HTMLElement;
341+
if (thumbnailContainer) {
342+
const regularPastedContent = await getClaudePastedContent(thumbnailContainer);
343+
if (regularPastedContent) {
344+
messageParts.push(regularPastedContent);
345+
await new Promise((resolve) => setTimeout(resolve, 100));
346+
}
347+
}
348+
}
349+
350+
// extract text content from user message
351+
const textContent = await extractFormattedText(div);
352+
if (textContent) {
353+
messageParts.push(textContent);
354+
}
153355
}
154356

155357
content = messageParts.join('\n');

0 commit comments

Comments
 (0)