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
1. Optionally scrape project-level artifacts instead of chat-level artifacts
2. Auto-open the sidebar as needed
3. Rename the zip file based on the project's name
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.

View File

@ -1,10 +1,61 @@
const ArtifactScraper = {
openArtifactList() {
getArtifactMenuButton() {
const artifactMenuButton = document.querySelector('button[aria-haspopup="menu"] svg[viewBox="0 0 256 256"] path[d*="M80,64a8,8,0,0,1,8-8H216"]');
return artifactMenuButton?.closest('button');
},
ensureArtifactSidebarOpen() {
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 menuButton = artifactMenuButton?.closest('button');
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) {
reject(new Error('Global artifact menu button not found'));
@ -13,7 +64,6 @@ const ArtifactScraper = {
let hasResolved = false;
// Listen for menu opening
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
@ -26,7 +76,6 @@ const ArtifactScraper = {
);
if (menuAdded) {
console.log('Artifact list menu has been opened');
observer.disconnect();
if (!hasResolved) {
hasResolved = true;
@ -39,8 +88,6 @@ const ArtifactScraper = {
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();
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
@ -50,7 +97,6 @@ const ArtifactScraper = {
});
menuButton.dispatchEvent(enterEvent);
// Fallback timeout so we don't accidentally listen forever.
setTimeout(() => {
if (!hasResolved) {
observer.disconnect();
@ -62,11 +108,14 @@ const ArtifactScraper = {
},
getArtifactListItems() {
if (!this.getArtifactMenuButton()) {
return {};
}
const menuItems = document.querySelectorAll('li[role="none"] div[role="menuitem"]');
const artifactHash = {};
if (menuItems.length === 0) {
console.log('No artifact list items found');
return artifactHash;
}
@ -74,7 +123,6 @@ const ArtifactScraper = {
const filenameDiv = item.querySelector('div.line-clamp-2');
if (filenameDiv) {
const filename = filenameDiv.textContent.trim();
// Check for aria-selected attribute or other indicators of selection
const isSelected = item.getAttribute('aria-selected') === 'true' ||
item.classList.contains('bg-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"]');
let targetItem = null;
// Find the matching artifact by filename
menuItems.forEach(item => {
const filenameDiv = item.querySelector('div.line-clamp-2');
if (filenameDiv && filenameDiv.textContent.trim() === filename) {
@ -105,11 +152,6 @@ const ArtifactScraper = {
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();
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
@ -119,7 +161,6 @@ const ArtifactScraper = {
});
targetItem.dispatchEvent(enterEvent);
// Wait for artifact to load
setTimeout(() => {
resolve(true);
}, 1000);
@ -159,17 +200,12 @@ const ArtifactScraper = {
async collectArtifacts() {
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;
await this.openArtifactList();
const artifactList = this.getArtifactListItems();
if (Object.keys(artifactList).length === 0) {
console.log('No artifacts found in list');
return { artifacts: artifactCollection, fileMap: fileMap };
}
@ -180,7 +216,6 @@ const ArtifactScraper = {
const content = await this.getArtifact(filename, isSelected);
if (filename === 'files.txt') {
// This is our special llm-generated mapping that we'll need to finish our export.
fileMap = content;
console.log(`✓ Found files.txt - storing in fileMap`);
} else {
@ -191,12 +226,10 @@ const ArtifactScraper = {
console.log(`✗ Failed to collect: ${filename} - ${error.message}`);
}
// Small delay between artifacts
await new Promise(resolve => setTimeout(resolve, 500));
}
return { artifacts: artifactCollection, fileMap: fileMap };
}
};
// No exports needed - will be in same scope after build