diff --git a/.github/workflows/pr-boxel-host.yml b/.github/workflows/pr-boxel-host.yml index a510f9f743..bf848a7b9b 100644 --- a/.github/workflows/pr-boxel-host.yml +++ b/.github/workflows/pr-boxel-host.yml @@ -59,12 +59,12 @@ jobs: RESOLVED_BASE_REALM_URL: https://realms-staging.stack.cards/base/ MATRIX_URL: https://matrix-staging.stack.cards MATRIX_SERVER_NAME: stack.cards - EXPERIMENTAL_AI_ENABLED: true S3_PREVIEW_BUCKET_NAME: boxel-host-preview.stack.cards AWS_S3_BUCKET: boxel-host-preview.stack.cards AWS_REGION: us-east-1 AWS_CLOUDFRONT_DISTRIBUTION: EU4RGLH4EOCHJ ENABLE_PLAYGROUND: true + AI_ASSISTANT_EXPERIMENTAL_ATTACHING_FILES_ENABLED: true with: package: boxel-host environment: staging @@ -90,12 +90,12 @@ jobs: RESOLVED_BASE_REALM_URL: https://app.boxel.ai/base/ MATRIX_URL: https://matrix.boxel.ai MATRIX_SERVER_NAME: boxel.ai - EXPERIMENTAL_AI_ENABLED: true S3_PREVIEW_BUCKET_NAME: boxel-host-preview.boxel.ai AWS_S3_BUCKET: boxel-host-preview.boxel.ai AWS_REGION: us-east-1 AWS_CLOUDFRONT_DISTRIBUTION: E2PZR9CIAW093B ENABLE_PLAYGROUND: true + AI_ASSISTANT_EXPERIMENTAL_ATTACHING_FILES_ENABLED: true with: package: boxel-host environment: production diff --git a/packages/base/file-api.gts b/packages/base/file-api.gts index 0f56996ca5..3079998385 100644 --- a/packages/base/file-api.gts +++ b/packages/base/file-api.gts @@ -14,6 +14,13 @@ class View extends Component { } +export type SerializedFile = { + sourceUrl: string; + url: string; + name: string; + contentType: string; +}; + export class FileDef extends BaseDef { static displayName = 'File'; static icon = FileIcon; @@ -21,7 +28,7 @@ export class FileDef extends BaseDef { @field sourceUrl = contains(StringField); @field url = contains(StringField); @field name = contains(StringField); - @field type = contains(StringField); + @field contentType = contains(StringField); static embedded: BaseDefComponent = View; static fitted: BaseDefComponent = View; @@ -33,7 +40,21 @@ export class FileDef extends BaseDef { sourceUrl: this.sourceUrl, url: this.url, name: this.name, - type: this.type, + contentType: this.contentType, }; } } + +export function createFileDef({ + url, + sourceUrl, + name, + contentType, +}: { + url?: string; + sourceUrl: string; + name?: string; + contentType?: string; +}) { + return new FileDef({ url, sourceUrl, name, contentType }); +} diff --git a/packages/base/matrix-event.gts b/packages/base/matrix-event.gts index ee1cd1d55b..91bd722a8a 100644 --- a/packages/base/matrix-event.gts +++ b/packages/base/matrix-event.gts @@ -16,6 +16,7 @@ import { APP_BOXEL_ROOM_SKILLS_EVENT_TYPE, APP_BOXEL_ACTIVE_LLM, } from '@cardstack/runtime-common/matrix-constants'; +import { type SerializedFile } from './file-api'; interface BaseMatrixEvent { sender: string; @@ -186,6 +187,7 @@ export interface CardMessageContent { data: { // we use this field over the wire since the matrix message protocol // limits us to 65KB per message + attachedFiles?: SerializedFile[]; attachedCardsEventIds?: string[]; attachedSkillEventIds?: string[]; // we materialize this field on the server from the card 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 bda326da24..dd73b61069 100644 --- a/packages/host/app/components/ai-assistant/attachment-picker/index.gts +++ b/packages/host/app/components/ai-assistant/attachment-picker/index.gts @@ -8,32 +8,42 @@ import { restartableTask } from 'ember-concurrency'; import { TrackedSet } from 'tracked-built-ins'; import { AddButton, Tooltip, Pill } from '@cardstack/boxel-ui/components'; -import { and, cn, gt, not } from '@cardstack/boxel-ui/helpers'; +import { and, cn, eq, gt, not } from '@cardstack/boxel-ui/helpers'; import { chooseCard, baseCardRef, isCardInstance, + chooseFile, } from '@cardstack/runtime-common'; import CardPill from '@cardstack/host/components/card-pill'; +import FilePill from '@cardstack/host/components/file-pill'; +import ENV from '@cardstack/host/config/environment'; import { type CardDef } from 'https://cardstack.com/base/card-api'; import { type FileDef } from 'https://cardstack.com/base/file-api'; +import { Submode } from '../../submode-switcher'; + interface Signature { Element: HTMLDivElement; Args: { autoAttachedCards?: TrackedSet; - autoAttachedFiles?: FileDef[]; cardsToAttach: CardDef[] | undefined; + autoAttachedFile?: FileDef; filesToAttach: FileDef[] | undefined; chooseCard: (card: CardDef) => void; removeCard: (card: CardDef) => void; + chooseFile: (file: FileDef) => void; + removeFile: (file: FileDef) => void; + submode: Submode; maxNumberOfItemsToAttach?: number; }; } +const isAttachingFilesEnabled = + ENV.featureFlags?.AI_ASSISTANT_EXPERIMENTAL_ATTACHING_FILES_ENABLED; const MAX_ITEMS_TO_DISPLAY = 4; export default class AiAssistantAttachmentPicker extends Component { @@ -64,6 +74,27 @@ export default class AiAssistantAttachmentPicker extends Component { @removeCard={{@removeCard}} /> {{/if}} + {{else if isAttachingFilesEnabled}} + {{#if (this.isAutoAttachedFile item)}} + + <:trigger> + + + <:content> + Currently opened file is shared automatically + + + {{else}} + + {{/if}} {{/if}} {{/each}} {{#if @@ -81,19 +112,35 @@ export default class AiAssistantAttachmentPicker extends Component { {{/if}} {{#if this.canDisplayAddButton}} - - - Add Card - - + {{#if (and (eq @submode 'code') isAttachingFilesEnabled)}} + + + Attach File + + + {{else}} + + + Add Card + + + {{/if}} {{/if}} - listing = directory( + private listing = directory( this, () => this.args.relativePath, () => this.args.realmURL, ); - @service declare cardService: CardService; - @service declare operatorModeStateService: OperatorModeStateService; - @service declare router: RouterService; + + @tracked private selectedFile?: LocalPath; + private openDirs: TrackedArray = new TrackedArray(); @action - openFile(entryPath: LocalPath) { - let fileUrl = new RealmPaths(this.args.realmURL).fileURL(entryPath); - this.operatorModeStateService.updateCodePath(fileUrl); + private selectFile(entryPath: LocalPath) { + this.selectedFile = entryPath; + this.args.onFileSelected?.(entryPath); } @action - toggleDirectory(entryPath: string) { - this.operatorModeStateService.toggleOpenDir(entryPath); + private selectDirectory(entryPath: string) { + for (let i = 0; i < this.openDirs.length; i++) { + if (this.openDirs[i].startsWith(entryPath)) { + let localParts = entryPath.split('/').filter((p) => p.trim() != ''); + localParts.pop(); + if (localParts.length) { + this.openDirs[i] = localParts.join('/') + '/'; + } else { + this.openDirs.splice(i, 1); + } + this.args.onDirectorySelected?.(entryPath); + return; + } else if (entryPath.startsWith(this.openDirs[i])) { + this.openDirs[i] = entryPath; + this.args.onDirectorySelected?.(entryPath); + return; + } + } + this.openDirs.push(entryPath); + this.args.onDirectorySelected?.(entryPath); } - private get scrollPositionKey() { - return this.operatorModeStateService.state.codePath?.toString(); + @action + private isSelectedFile(entryPath: LocalPath) { + return this.args.selectedFile + ? this.args.selectedFile === entryPath + : this.selectedFile === entryPath; } -} - -function fileIsSelected( - localPath: string, - operatorModeStateService: OperatorModeStateService, -) { - return operatorModeStateService.codePathRelativeToRealm === localPath; -} -function isOpen( - path: string, - operatorModeStateService: OperatorModeStateService, -) { - let directoryIsPersistedOpen = ( - operatorModeStateService.currentRealmOpenDirs ?? [] - ).find((item) => item.startsWith(path)); - - return directoryIsPersistedOpen; + @action + private isOpenDirectory(entryPath: LocalPath) { + return this.args.openDirs + ? this.args.openDirs.find((item) => item.startsWith(entryPath)) + : this.openDirs.find((item) => item.startsWith(entryPath)); + } } diff --git a/packages/host/app/components/editor/file-tree.gts b/packages/host/app/components/editor/file-tree.gts index a566358c62..80c48e72e8 100644 --- a/packages/host/app/components/editor/file-tree.gts +++ b/packages/host/app/components/editor/file-tree.gts @@ -8,11 +8,14 @@ import { tracked } from '@glimmer/tracking'; import { restartableTask, timeout } from 'ember-concurrency'; import { Label, RealmIcon, Tooltip } from '@cardstack/boxel-ui/components'; +import { not } from '@cardstack/boxel-ui/helpers'; import { IconPencilNotCrossedOut, IconPencilCrossedOut, } from '@cardstack/boxel-ui/icons'; +import { type LocalPath } from '@cardstack/runtime-common'; + import RealmService from '@cardstack/host/services/realm'; import WithLoadedRealm from '../with-loaded-realm'; @@ -22,55 +25,72 @@ import Directory from './directory'; interface Signature { Args: { realmURL: URL; + selectedFile?: LocalPath; + openDirs?: LocalPath[]; + onFileSelected?: (entryPath: LocalPath) => void; + onDirectorySelected?: (entryPath: LocalPath) => void; + scrollPositionKey?: LocalPath; + hideRealmInfo?: boolean; }; } export default class FileTree extends Component {