diff --git a/packages/host/app/components/ai-assistant/attachment-picker/attached-items.gts b/packages/host/app/components/ai-assistant/attachment-picker/attached-items.gts index 5ecf174a3c..14b292c29d 100644 --- a/packages/host/app/components/ai-assistant/attachment-picker/attached-items.gts +++ b/packages/host/app/components/ai-assistant/attachment-picker/attached-items.gts @@ -33,7 +33,7 @@ interface Signature { Args: { items: (CardDef | FileDef | CardErrorJSONAPI)[]; autoAttachedCardIds?: TrackedSet; - autoAttachedFile?: FileDef; + autoAttachedFiles?: FileDef[]; removeCard: (cardId: string) => void; removeFile: (file: FileDef) => void; chooseCard?: (cardId: string) => void; @@ -63,7 +63,11 @@ export default class AttachedItems extends Component { }; private isAutoAttachedFile = (file: FileDef): boolean => { - return this.args.autoAttachedFile?.sourceUrl === file.sourceUrl; + return ( + this.args.autoAttachedFiles?.some( + (autoFile) => autoFile.sourceUrl === file.sourceUrl, + ) ?? false + ); }; @action diff --git a/packages/host/app/components/ai-assistant/attachment-picker/index.gts b/packages/host/app/components/ai-assistant/attachment-picker/index.gts index 1fa77a4743..27c91b742a 100644 --- a/packages/host/app/components/ai-assistant/attachment-picker/index.gts +++ b/packages/host/app/components/ai-assistant/attachment-picker/index.gts @@ -23,7 +23,7 @@ interface Signature { Args: { autoAttachedCardIds?: TrackedSet; cardIdsToAttach: string[] | undefined; - autoAttachedFile?: FileDef; + autoAttachedFiles?: FileDef[]; filesToAttach: FileDef[] | undefined; chooseCard: (cardId: string) => void; removeCard: (cardId: string) => void; @@ -37,7 +37,7 @@ interface Signature { typeof AttachedItems, | 'items' | 'autoAttachedCardIds' - | 'autoAttachedFile' + | 'autoAttachedFiles' | 'removeCard' | 'removeFile' | 'chooseCard' @@ -59,7 +59,7 @@ export default class AiAssistantAttachmentPicker extends Component { isLoaded=this.isLoaded items=this.items autoAttachedCardIds=@autoAttachedCardIds - autoAttachedFile=@autoAttachedFile + autoAttachedFiles=@autoAttachedFiles removeCard=@removeCard removeFile=@removeFile chooseCard=@chooseCard @@ -109,18 +109,20 @@ export default class AiAssistantAttachmentPicker extends Component { private get files() { let files = this.args.filesToAttach ?? []; - if (!this.args.autoAttachedFile) { + let autoAttachedFiles = this.args.autoAttachedFiles ?? []; + + if (autoAttachedFiles.length === 0) { return files; } - if ( - files.some( - (file) => file.sourceUrl === this.args.autoAttachedFile?.sourceUrl, - ) - ) { + let autoFilesToPrepend = autoAttachedFiles.filter( + (file) => !files.some((item) => item.sourceUrl === file.sourceUrl), + ); + + if (autoFilesToPrepend.length === 0) { return files; } - return [this.args.autoAttachedFile, ...files]; + return [...autoFilesToPrepend, ...files]; } } diff --git a/packages/host/app/components/ai-assistant/attachment-picker/usage.gts b/packages/host/app/components/ai-assistant/attachment-picker/usage.gts index 411deb3821..fa01246732 100644 --- a/packages/host/app/components/ai-assistant/attachment-picker/usage.gts +++ b/packages/host/app/components/ai-assistant/attachment-picker/usage.gts @@ -18,7 +18,7 @@ export default class AiAssistantCardPickerUsage extends Component { cardIds: TrackedArray = new TrackedArray([]); @tracked maxNumberOfCards: number | undefined = undefined; @tracked autoAttachedCardIds?: TrackedSet = new TrackedSet(); - @tracked autoAttachedFile?: FileDef | undefined; + @tracked autoAttachedFiles?: FileDef[]; @tracked filesToAttach: TrackedArray = new TrackedArray([]); @action chooseCard(cardId: string) { @@ -60,7 +60,7 @@ export default class AiAssistantCardPickerUsage extends Component { @removeCard={{this.removeCard}} @chooseFile={{this.chooseFile}} @removeFile={{this.removeFile}} - @autoAttachedFile={{this.autoAttachedFile}} + @autoAttachedFiles={{this.autoAttachedFiles}} @filesToAttach={{this.filesToAttach}} as |AttachedItems AttachButton| > @@ -81,9 +81,9 @@ export default class AiAssistantCardPickerUsage extends Component { @value={{this.autoAttachedCardIds}} /> { @removeCard={{this.removeCard}} @chooseFile={{this.chooseFile}} @removeFile={{this.removeFile}} - @autoAttachedFile={{this.autoAttachedFile}} + @autoAttachedFiles={{this.autoAttachedFiles}} @filesToAttach={{this.filesToAttach}} @autoAttachedCardTooltipMessage={{if (eq this.operatorModeStateService.state.submode Submodes.Code) @@ -450,7 +451,7 @@ export default class Room extends Component { submode: () => this.operatorModeStateService.state.submode, moduleInspectorPanel: () => this.operatorModeStateService.moduleInspectorPanel, - autoAttachedFileUrl: () => this.autoAttachedFileUrl, + autoAttachedFileUrls: () => this.autoAttachedFileUrls, playgroundPanelCardId: () => this.playgroundPanelCardId, activeSpecId: () => this.specPanelService.specSelection, topMostStackItems: () => this.topMostStackItems, @@ -459,7 +460,7 @@ export default class Room extends Component { }); private removedAttachedCardIds = new TrackedArray(); private removedAttachedFileUrls: string[] = []; - private lastAutoAttachedFileUrl: string | undefined; + private lastAutoAttachedFileUrlsKey: string | undefined; private getConversationScrollability: (() => boolean) | undefined; private scrollConversationToBottom: (() => void) | undefined; private roomScrollState: WeakMap< @@ -530,59 +531,98 @@ export default class Room extends Component { // when the user opens a different file and then returns to this one. @use private autoAttachedFileResource = resource(() => { let state = new TrackedObject<{ - value: FileDef | undefined; - remove: () => void; + value: FileDef[]; + remove: (sourceUrl?: string) => void; }>({ - value: undefined, - remove: () => { - state.value = undefined; + value: [], + remove: (sourceUrl?: string) => { + if (!sourceUrl) { + state.value = []; + return; + } + state.value = state.value.filter( + (file) => file.sourceUrl !== sourceUrl, + ); }, }); - let autoAttachedFileUrl = this.autoAttachedFileUrl; + let autoAttachedFileUrls = this.autoAttachedFileUrls; let manuallyAttachedFiles = this.filesToAttach; + let autoAttachedFileUrlsKey = autoAttachedFileUrls.join('\n'); let removedFileUrls: string[]; - if (autoAttachedFileUrl !== this.lastAutoAttachedFileUrl) { + if (autoAttachedFileUrlsKey !== this.lastAutoAttachedFileUrlsKey) { this.removedAttachedFileUrls.splice(0); removedFileUrls = this.removedAttachedFileUrls; - this.lastAutoAttachedFileUrl = autoAttachedFileUrl; + this.lastAutoAttachedFileUrlsKey = autoAttachedFileUrlsKey; } else { removedFileUrls = this.removedAttachedFileUrls; } - let isManuallyAttached = manuallyAttachedFiles.some( - (file) => file.sourceUrl === autoAttachedFileUrl, - ); - let isRemoved = autoAttachedFileUrl - ? removedFileUrls.includes(autoAttachedFileUrl) - : false; + let candidateUrls = autoAttachedFileUrls.filter((url) => { + if (!url) { + return false; + } + let isManuallyAttached = manuallyAttachedFiles.some( + (file) => file.sourceUrl === url, + ); + let isRemoved = removedFileUrls.includes(url); + return !isManuallyAttached && !isRemoved; + }); - if (!autoAttachedFileUrl || isManuallyAttached || isRemoved) { - state.value = undefined; - } else { - state.value = this.matrixService.fileAPI.createFileDef({ - sourceUrl: autoAttachedFileUrl, - name: autoAttachedFileUrl.split('/').pop(), - }); - } + state.value = candidateUrls.map((url) => + this.matrixService.fileAPI.createFileDef({ + sourceUrl: url, + name: url.split('/').pop(), + }), + ); return state; }); - private get autoAttachedFileUrl() { - return this.operatorModeStateService.state.codePath?.href; + private get autoAttachedFileUrls() { + let codePathUrl = this.operatorModeStateService.state.codePath?.href; + if (!codePathUrl) { + return []; + } + + let urls = [codePathUrl]; + + if (codePathUrl.endsWith('.json')) { + let openFile = this.operatorModeStateService.openFile?.current; + if (openFile && isReady(openFile)) { + try { + let fileContent = JSON.parse(openFile.content); + let adoptsFrom = fileContent?.data?.meta?.adoptsFrom; + if (adoptsFrom?.module) { + let moduleURLWithExtension = new URL( + adoptsFrom.module.endsWith('.gts') + ? adoptsFrom.module + : `${adoptsFrom.module}.gts`, + openFile.url, + ); + if (!urls.includes(moduleURLWithExtension.href)) { + urls.push(moduleURLWithExtension.href); + } + } + } catch (_error) { + // If JSON parse fails, fall back to just the current file URL. + } + } + } + + return urls; } - private get autoAttachedFile() { + private get autoAttachedFiles() { return this.operatorModeStateService.state.submode === Submodes.Code ? this.autoAttachedFileResource.value - : undefined; + : []; } private get removeAutoAttachedFile() { - return () => { - this.autoAttachedFileResource.remove(); + return (sourceUrl?: string) => { + this.autoAttachedFileResource.remove(sourceUrl); }; } @@ -939,8 +979,8 @@ export default class Room extends Component { } let files = []; - if (this.autoAttachedFile) { - files.push(this.autoAttachedFile); + if (this.autoAttachedFiles.length > 0) { + files.push(...this.autoAttachedFiles); } files.push(...this.filesToAttach); @@ -996,7 +1036,7 @@ export default class Room extends Component { private chooseFile(file: FileDef) { // handle the case where auto-attached file pill is clicked if (this.isAutoAttachedFile(file)) { - this.removeAutoAttachedFile(); + this.removeAutoAttachedFile(file.sourceUrl ?? undefined); } let files = this.filesToAttach; @@ -1007,13 +1047,15 @@ export default class Room extends Component { @action private isAutoAttachedFile(file: FileDef) { - return this.autoAttachedFile?.sourceUrl === file.sourceUrl; + return this.autoAttachedFiles.some( + (autoFile) => autoFile.sourceUrl === file.sourceUrl, + ); } @action private removeFile(file: FileDef) { if (this.isAutoAttachedFile(file)) { - this.removeAutoAttachedFile(); + this.removeAutoAttachedFile(file.sourceUrl ?? undefined); return; } @@ -1228,7 +1270,7 @@ export default class Room extends Component { return ( this.filesToAttach?.length || this.cardIdsToAttach?.length || - this.autoAttachedFile || + this.autoAttachedFiles.length > 0 || this.autoAttachedCardIds?.size ); } diff --git a/packages/host/app/resources/auto-attached-card.ts b/packages/host/app/resources/auto-attached-card.ts index 505d0c48e5..8f89bda042 100644 --- a/packages/host/app/resources/auto-attached-card.ts +++ b/packages/host/app/resources/auto-attached-card.ts @@ -23,7 +23,7 @@ interface Args { named: { submode: Submode; moduleInspectorPanel: string | undefined; // 'preview' | 'spec' | 'schema' | 'card-renderer' - autoAttachedFileUrl: string | undefined; + autoAttachedFileUrls: string[] | undefined; playgroundPanelCardId: string | undefined; activeSpecId: string | null | undefined; // selected spec card ID from SpecPanelService topMostStackItems: StackItem[]; @@ -45,7 +45,7 @@ export class AutoAttachment extends Resource { const { submode, moduleInspectorPanel, - autoAttachedFileUrl, + autoAttachedFileUrls, playgroundPanelCardId, activeSpecId, topMostStackItems, @@ -55,7 +55,7 @@ export class AutoAttachment extends Resource { this.calculateAutoAttachments.perform( submode, moduleInspectorPanel, - autoAttachedFileUrl, + autoAttachedFileUrls, playgroundPanelCardId, activeSpecId, topMostStackItems, @@ -68,7 +68,7 @@ export class AutoAttachment extends Resource { async ( submode: Submode, moduleInspectorPanel: string | undefined, - autoAttachedFileUrl: string | undefined, + autoAttachedFileUrls: string[] | undefined, playgroundPanelCardId: string | undefined, activeSpecId: string | null | undefined, topMostStackItems: StackItem[], @@ -97,8 +97,11 @@ export class AutoAttachment extends Resource { this.cardIds.add(item.id); } } else if (submode === Submodes.Code) { - let cardId = autoAttachedFileUrl?.endsWith('.json') - ? autoAttachedFileUrl?.replace(/\.json$/, '') + let cardFileUrl = autoAttachedFileUrls?.find((url) => + url.endsWith('.json'), + ); + let cardId = cardFileUrl + ? cardFileUrl.replace(/\.json$/, '') : undefined; if ( cardId && @@ -133,7 +136,7 @@ export function getAutoAttachment( args: { submode: () => Submode; moduleInspectorPanel: () => string | undefined; - autoAttachedFileUrl: () => string | undefined; + autoAttachedFileUrls: () => string[] | undefined; playgroundPanelCardId: () => string | undefined; activeSpecId: () => string | null | undefined; topMostStackItems: () => StackItem[]; @@ -145,7 +148,7 @@ export function getAutoAttachment( named: { submode: args.submode(), moduleInspectorPanel: args.moduleInspectorPanel(), - autoAttachedFileUrl: args.autoAttachedFileUrl(), + autoAttachedFileUrls: args.autoAttachedFileUrls(), playgroundPanelCardId: args.playgroundPanelCardId(), activeSpecId: args.activeSpecId(), topMostStackItems: args.topMostStackItems(), diff --git a/packages/host/tests/acceptance/ai-assistant-test.gts b/packages/host/tests/acceptance/ai-assistant-test.gts index b2e3c43253..6eaa0dbe2b 100644 --- a/packages/host/tests/acceptance/ai-assistant-test.gts +++ b/packages/host/tests/acceptance/ai-assistant-test.gts @@ -1278,7 +1278,17 @@ module('Acceptance | AI Assistant tests', function (hooks) { await click('[data-test-file="Person/fadhlan.json"]'); assert.dom('[data-test-attached-card]').exists({ count: 2 }); assert.dom('[data-test-autoattached-card]').exists({ count: 1 }); - assert.dom('[data-test-autoattached-file]').exists({ count: 1 }); + assert.dom('[data-test-autoattached-file]').exists({ count: 2 }); + assert + .dom( + `[data-test-attached-file="${testRealmURL}Person/fadhlan.json"][data-test-autoattached-file]`, + ) + .exists(); + assert + .dom( + `[data-test-attached-file="${testRealmURL}person.gts"][data-test-autoattached-file]`, + ) + .exists(); assert.dom(`[data-test-attached-card="${testRealmURL}Pet/mango"]`).exists(); assert .dom( diff --git a/packages/matrix/tests/messages.spec.ts b/packages/matrix/tests/messages.spec.ts index 50e61a05aa..84ca57e62f 100644 --- a/packages/matrix/tests/messages.spec.ts +++ b/packages/matrix/tests/messages.spec.ts @@ -295,7 +295,10 @@ test.describe('Room messages', () => { .click(); await page.locator(`[data-test-boxel-menu-item-text="Code"]`).click(); - await expect(page.locator(`[data-test-attached-file]`)).toHaveCount(1); + await expect(page.locator(`[data-test-attached-file]`)).toHaveCount(2); + await expect( + page.locator(`[data-test-attached-file="${appURL}/person.gts"]`), + ).toHaveCount(1); await expect( page.locator(`[data-test-attached-file="${appURL}/hassan.json"]`), ).toHaveCount(1); @@ -357,6 +360,9 @@ test.describe('Room messages', () => { page.locator(`[data-test-attached-card="${appURL}/hassan"]`), ).toHaveCount(1); await expect(page.locator(`[data-test-attached-file]`)).toHaveCount(1); + await expect( + page.locator(`[data-test-attached-file="${appURL}/person.gts"]`), + ).toHaveCount(1); await expect( page.locator(`[data-test-attached-file="${appURL}/hassan.json"]`), ).toHaveCount(1);