Pre-open the artifact menu if it's closed

This commit is contained in:
2025-07-13 17:14:51 -04:00
parent 437c41ec51
commit 53df3702ea
2 changed files with 60 additions and 28 deletions

View File

@ -37,5 +37,4 @@ If you've followed the other steps, you should see some activity in the chat pag
## Changes under consideration ## Changes under consideration
1. Optionally scrape project-level artifacts instead of chat-level artifacts 1. Optionally scrape project-level artifacts instead of chat-level artifacts
2. Auto-open the sidebar as needed 2. Fix the scraper to work with single-artifact chats that don't have a selector button. Not sure how we'll get the artifact name here.
3. Rename the zip file based on the project's name

View File

@ -1,10 +1,61 @@
const ArtifactScraper = { const ArtifactScraper = {
openArtifactList() { getArtifactMenuButton() {
return new Promise((resolve, reject) => {
// Find the global artifact menu button (has the hamburger/list icon)
const artifactMenuButton = document.querySelector('button[aria-haspopup="menu"] svg[viewBox="0 0 256 256"] path[d*="M80,64a8,8,0,0,1,8-8H216"]'); const artifactMenuButton = document.querySelector('button[aria-haspopup="menu"] svg[viewBox="0 0 256 256"] path[d*="M80,64a8,8,0,0,1,8-8H216"]');
const menuButton = artifactMenuButton?.closest('button'); return artifactMenuButton?.closest('button');
},
ensureArtifactSidebarOpen() {
return new Promise((resolve, reject) => {
if (this.getArtifactMenuButton()) {
resolve(true);
return;
}
const toggleButton = document.querySelector('button[data-state="closed"] svg[viewBox="0 0 20 20"] path[d*="M8.14648 4.64648C8.34176 4.45136"]');
const button = toggleButton?.closest('button');
if (!button) {
reject(new Error('Artifact sidebar toggle button not found'));
return;
}
let hasResolved = false;
const observer = new MutationObserver((mutations) => {
if (this.getArtifactMenuButton()) {
observer.disconnect();
if (!hasResolved) {
hasResolved = true;
resolve(true);
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
button.click();
setTimeout(() => {
if (!hasResolved) {
observer.disconnect();
hasResolved = true;
reject(new Error('Failed to open artifact sidebar - timeout'));
}
}, 3000);
});
},
openArtifactList() {
return new Promise(async (resolve, reject) => {
try {
await this.ensureArtifactSidebarOpen();
} catch (error) {
reject(new Error(`Failed to open artifact sidebar: ${error.message}`));
return;
}
const menuButton = this.getArtifactMenuButton();
if (!menuButton) { if (!menuButton) {
reject(new Error('Global artifact menu button not found')); reject(new Error('Global artifact menu button not found'));
@ -13,7 +64,6 @@ const ArtifactScraper = {
let hasResolved = false; let hasResolved = false;
// Listen for menu opening
const observer = new MutationObserver((mutations) => { const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => { mutations.forEach((mutation) => {
if (mutation.type === 'childList') { if (mutation.type === 'childList') {
@ -26,7 +76,6 @@ const ArtifactScraper = {
); );
if (menuAdded) { if (menuAdded) {
console.log('Artifact list menu has been opened');
observer.disconnect(); observer.disconnect();
if (!hasResolved) { if (!hasResolved) {
hasResolved = true; hasResolved = true;
@ -39,8 +88,6 @@ const ArtifactScraper = {
observer.observe(document.body, { childList: true, subtree: true }); observer.observe(document.body, { childList: true, subtree: true });
// Focus and send Enter key event to open menu. Click doesn't actually work,
// likely due to react pseudoevents.
menuButton.focus(); menuButton.focus();
const enterEvent = new KeyboardEvent('keydown', { const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter', key: 'Enter',
@ -50,7 +97,6 @@ const ArtifactScraper = {
}); });
menuButton.dispatchEvent(enterEvent); menuButton.dispatchEvent(enterEvent);
// Fallback timeout so we don't accidentally listen forever.
setTimeout(() => { setTimeout(() => {
if (!hasResolved) { if (!hasResolved) {
observer.disconnect(); observer.disconnect();
@ -62,11 +108,14 @@ const ArtifactScraper = {
}, },
getArtifactListItems() { getArtifactListItems() {
if (!this.getArtifactMenuButton()) {
return {};
}
const menuItems = document.querySelectorAll('li[role="none"] div[role="menuitem"]'); const menuItems = document.querySelectorAll('li[role="none"] div[role="menuitem"]');
const artifactHash = {}; const artifactHash = {};
if (menuItems.length === 0) { if (menuItems.length === 0) {
console.log('No artifact list items found');
return artifactHash; return artifactHash;
} }
@ -74,7 +123,6 @@ const ArtifactScraper = {
const filenameDiv = item.querySelector('div.line-clamp-2'); const filenameDiv = item.querySelector('div.line-clamp-2');
if (filenameDiv) { if (filenameDiv) {
const filename = filenameDiv.textContent.trim(); const filename = filenameDiv.textContent.trim();
// Check for aria-selected attribute or other indicators of selection
const isSelected = item.getAttribute('aria-selected') === 'true' || const isSelected = item.getAttribute('aria-selected') === 'true' ||
item.classList.contains('bg-accent-secondary-100') || item.classList.contains('bg-accent-secondary-100') ||
item.classList.contains('border-accent-secondary-100'); item.classList.contains('border-accent-secondary-100');
@ -92,7 +140,6 @@ const ArtifactScraper = {
const menuItems = document.querySelectorAll('li[role="none"] div[role="menuitem"]'); const menuItems = document.querySelectorAll('li[role="none"] div[role="menuitem"]');
let targetItem = null; let targetItem = null;
// Find the matching artifact by filename
menuItems.forEach(item => { menuItems.forEach(item => {
const filenameDiv = item.querySelector('div.line-clamp-2'); const filenameDiv = item.querySelector('div.line-clamp-2');
if (filenameDiv && filenameDiv.textContent.trim() === filename) { if (filenameDiv && filenameDiv.textContent.trim() === filename) {
@ -105,11 +152,6 @@ const ArtifactScraper = {
return; return;
} }
console.log(`Selecting artifact: ${filename}`);
// Focus and send Enter key event to select the item
// Click may or may not work here, but keydown was needed elsewhere, so we're
// sticking to it here.
targetItem.focus(); targetItem.focus();
const enterEvent = new KeyboardEvent('keydown', { const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter', key: 'Enter',
@ -119,7 +161,6 @@ const ArtifactScraper = {
}); });
targetItem.dispatchEvent(enterEvent); targetItem.dispatchEvent(enterEvent);
// Wait for artifact to load
setTimeout(() => { setTimeout(() => {
resolve(true); resolve(true);
}, 1000); }, 1000);
@ -159,17 +200,12 @@ const ArtifactScraper = {
async collectArtifacts() { async collectArtifacts() {
const artifactCollection = {}; const artifactCollection = {};
// There's no path information in a Claude artifact, just filenames.
// However, the LLM itself is smart enough to know where the files should go.
// So our strategy is to make it save those paths to a files.txt artifact and store
// that separately for use in making a final map of artifacts to files for our zip export.
let fileMap = null; let fileMap = null;
await this.openArtifactList(); await this.openArtifactList();
const artifactList = this.getArtifactListItems(); const artifactList = this.getArtifactListItems();
if (Object.keys(artifactList).length === 0) { if (Object.keys(artifactList).length === 0) {
console.log('No artifacts found in list');
return { artifacts: artifactCollection, fileMap: fileMap }; return { artifacts: artifactCollection, fileMap: fileMap };
} }
@ -180,7 +216,6 @@ const ArtifactScraper = {
const content = await this.getArtifact(filename, isSelected); const content = await this.getArtifact(filename, isSelected);
if (filename === 'files.txt') { if (filename === 'files.txt') {
// This is our special llm-generated mapping that we'll need to finish our export.
fileMap = content; fileMap = content;
console.log(`✓ Found files.txt - storing in fileMap`); console.log(`✓ Found files.txt - storing in fileMap`);
} else { } else {
@ -191,12 +226,10 @@ const ArtifactScraper = {
console.log(`✗ Failed to collect: ${filename} - ${error.message}`); console.log(`✗ Failed to collect: ${filename} - ${error.message}`);
} }
// Small delay between artifacts
await new Promise(resolve => setTimeout(resolve, 500)); await new Promise(resolve => setTimeout(resolve, 500));
} }
return { artifacts: artifactCollection, fileMap: fileMap }; return { artifacts: artifactCollection, fileMap: fileMap };
} }
}; };
// No exports needed - will be in same scope after build // No exports needed - will be in same scope after build