Skip to content

Commit

Permalink
Display attached card pills in sent message (#1005)
Browse files Browse the repository at this point in the history
* allow messages to scroll into view

* use Message component to display cards and card errors

* revert some changes in base room card

* add snapshot for message component with error state

* update usage component

* distinguish footer area from sent message background

* lint fix
  • Loading branch information
burieberry authored Feb 1, 2024
1 parent 040457a commit 2aa6fd2
Show file tree
Hide file tree
Showing 10 changed files with 262 additions and 145 deletions.
58 changes: 33 additions & 25 deletions packages/base/room.gts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
type LooseSingleCardDocument,
type CodeRef,
} from '@cardstack/runtime-common';
//@ts-expect-error cached type not available yet
import { cached } from '@glimmer/tracking';

// this is so we can have triple equals equivalent room member cards
function upsertRoomMember({
Expand Down Expand Up @@ -172,7 +174,11 @@ class EmbeddedMessageField extends Component<typeof MessageField> {
data-test-message-idx={{@model.index}}
data-test-message-cards
>
{{#each this.attachedResources as |cardResource|}}
<div>
{{@fields.message}}
</div>

{{#each @model.attachedResources as |cardResource|}}
{{#if cardResource.cardError}}
<div data-test-card-error={{cardResource.cardError.id}} class='error'>
Error: cannot render card
Expand All @@ -197,28 +203,6 @@ class EmbeddedMessageField extends Component<typeof MessageField> {
</style>
</template>

attachedCardResources: string[] | undefined = this.args.model.attachedCardIds;

get attachedResources(): AttachedCardResource[] | undefined {
if (!this.attachedCardResources?.length) {
return undefined;
}
let cards = this.attachedCardResources.map((id) => {
let card = getCard(new URL(id));
if (!card) {
return {
card: undefined,
cardError: {
id,
error: new Error(`cannot find card for id "${id}"`),
},
};
}
return card;
});
return cards;
}

get timestamp() {
if (!this.args.model.created) {
throw new Error(`message created time is undefined`);
Expand All @@ -231,7 +215,7 @@ type JSONValue = string | number | boolean | null | JSONObject | [JSONValue];

type JSONObject = { [x: string]: JSONValue };

export type PatchObject = { patch: { attributes: JSONObject }; id: string };
type PatchObject = { patch: { attributes: JSONObject }; id: string };

class PatchObjectField extends FieldDef {
static [primitive]: PatchObject;
Expand Down Expand Up @@ -260,6 +244,27 @@ export class MessageField extends FieldDef {
static embedded = EmbeddedMessageField;
// The edit template is meant to be read-only, this field card is not mutable
static edit = class Edit extends JSONView {};

@cached
get attachedResources(): AttachedCardResource[] | undefined {
if (!this.attachedCardIds?.length) {
return undefined;
}
let cards = this.attachedCardIds.map((id) => {
let card = getCard(new URL(id));
if (!card) {
return {
card: undefined,
cardError: {
id,
error: new Error(`cannot find card for id "${id}"`),
},
};
}
return card;
});
return cards;
}
}

interface RoomState {
Expand Down Expand Up @@ -467,7 +472,10 @@ export class RoomField extends FieldDef {
if (attachedCardIds.length === 0) {
throw new Error(`cannot handle cards in room without an ID`);
}
messageField = new MessageField({ ...cardArgs, attachedCardIds });
messageField = new MessageField({
...cardArgs,
attachedCardIds,
});
} else if (event.content.msgtype === 'org.boxel.command') {
// We only handle patches for now
let command = event.content.data.command;
Expand Down
84 changes: 51 additions & 33 deletions packages/host/app/components/ai-assistant/card-picker/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ import { IconX } from '@cardstack/boxel-ui/icons';

import { chooseCard, baseCardRef } from '@cardstack/runtime-common';

import Pill from '@cardstack/host/components/pill';
import RealmInfoProvider from '@cardstack/host/components/operator-mode/realm-info-provider';

import { type CardDef } from 'https://cardstack.com/base/card-api';

import RealmIcon from '../../operator-mode/realm-icon';
import Pill from '../../pill';

interface Signature {
Element: HTMLDivElement;
Args: {
Expand All @@ -30,23 +33,41 @@ export default class AiAssistantCardPicker extends Component<Signature> {
<template>
<div class='card-picker'>
{{#each this.cardsToDisplay as |card i|}}
<Pill
@inert={{true}}
class={{cn
'card-pill'
is-autoattached=(eq card.id @autoAttachedCard.id)
}}
data-test-pill-index={{i}}
data-test-selected-card={{card.id}}
>
<div class='card-title'>{{getDisplayTitle card}}</div>
<IconButton
class='remove-button'
@icon={{IconX}}
{{on 'click' (fn @removeCard card)}}
data-test-remove-card-btn={{i}}
/>
</Pill>
{{#if card.id}}
<Pill
@inert={{true}}
class={{cn
'card-pill'
is-autoattached=(eq card.id @autoAttachedCard.id)
}}
data-test-pill-index={{i}}
data-test-selected-card={{card.id}}
>
<:icon>
<RealmInfoProvider @fileURL={{card.id}}>
<:ready as |realmInfo|>
<RealmIcon
@realmIconURL={{realmInfo.iconURL}}
@realmName={{realmInfo.name}}
width='18'
height='18'
/>
</:ready>
</RealmInfoProvider>
</:icon>
<:default>
<div class='card-title'>
{{if card.title card.title 'Untitled Card'}}
</div>
<IconButton
class='remove-button'
@icon={{IconX}}
{{on 'click' (fn @removeCard card)}}
data-test-remove-card-btn={{i}}
/>
</:default>
</Pill>
{{/if}}
{{/each}}
{{#if this.canDisplayAddButton}}
<AddButton
Expand Down Expand Up @@ -81,17 +102,6 @@ export default class AiAssistantCardPicker extends Component<Signature> {
box-shadow: none;
background-color: var(--boxel-highlight-hover);
}
.card-pill {
background-color: var(--boxel-light);
border: 1px solid var(--boxel-400);
height: var(--pill-height);
}
.card-title {
max-width: var(--pill-content-max-width);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.remove-button {
--boxel-icon-button-width: 25px;
--boxel-icon-button-height: 25px;
Expand All @@ -102,6 +112,18 @@ export default class AiAssistantCardPicker extends Component<Signature> {
.remove-button:hover:not(:disabled) {
--icon-color: var(--boxel-highlight);
}
.card-pill {
--pill-icon-size: 18px;
background-color: var(--boxel-light);
border: 1px solid var(--boxel-400);
height: 1.875rem;
}
.card-title {
max-width: 10rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.is-autoattached {
border-style: dashed;
}
Expand Down Expand Up @@ -138,7 +160,3 @@ export default class AiAssistantCardPicker extends Component<Signature> {
return chosenCard;
});
}

function getDisplayTitle(card: CardDef) {
return card.title || card.constructor.displayName || 'Untitled Card';
}
43 changes: 41 additions & 2 deletions packages/host/app/components/ai-assistant/message/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import type { SafeString } from '@ember/template';
import Component from '@glimmer/component';

import { format as formatDate, formatISO } from 'date-fns';
import Modifier from 'ember-modifier';

import { Button } from '@cardstack/boxel-ui/components';
import { cn } from '@cardstack/boxel-ui/helpers';
import { FailureBordered } from '@cardstack/boxel-ui/icons';

import { type CardDef } from 'https://cardstack.com/base/card-api';

import assistantIcon1x from '../ai-assist-icon.webp';
import assistantIcon2x from '../[email protected]';
import assistantIcon3x from '../[email protected]';
Expand All @@ -21,17 +24,24 @@ interface Signature {
datetime: Date;
isFromAssistant: boolean;
profileAvatar?: ComponentLike;
attachedCards?: CardDef[];
errorMessage?: string;
retryAction?: () => void;
};
Blocks: { default: [] };
}

// TODO: Update Boxel::Message component
class ScrollIntoView extends Modifier {
modify(element: HTMLElement) {
element.scrollIntoView();
}
}

export default class AiAssistantMessage extends Component<Signature> {
<template>
<div
class={{cn 'ai-assistant-message' is-from-assistant=@isFromAssistant}}
{{ScrollIntoView}}
data-test-ai-assistant-message
...attributes
>
Expand All @@ -53,7 +63,10 @@ export default class AiAssistantMessage extends Component<Signature> {
{{#if @errorMessage}}
<div class='error-container'>
<FailureBordered class='error-icon' />
<div class='error-message'>{{@errorMessage}}</div>
<div class='error-message' data-test-card-error>
Error:
{{@errorMessage}}
</div>
{{#if @retryAction}}
<Button
{{on 'click' @retryAction}}
Expand All @@ -66,13 +79,25 @@ export default class AiAssistantMessage extends Component<Signature> {
{{/if}}
</div>
{{/if}}

<div class='content'>
{{@formattedMessage}}

<div>{{yield}}</div>

{{#if @attachedCards.length}}
<div class='cards' data-test-message-cards>
{{#each this.cardResources as |resource|}}
<div data-test-message-card={{resource.card.id}}>
<resource.component />
</div>
{{/each}}
</div>
{{/if}}
</div>
</div>
</div>

<style>
.ai-assistant-message {
--ai-assistant-message-avatar-size: 1.25rem; /* 20px. */
Expand Down Expand Up @@ -161,8 +186,22 @@ export default class AiAssistantMessage extends Component<Signature> {
--boxel-button-min-width: max-content;
border-color: var(--boxel-light);
}
.cards {
color: var(--boxel-dark);
display: flex;
flex-wrap: wrap;
gap: var(--boxel-sp-xxs);
}
</style>
</template>

private get cardResources() {
return this.args.attachedCards?.map((card) => ({
card,
component: card.constructor.getComponent(card, 'atom'),
}));
}
}

interface AiAssistantConversationSignature {
Expand Down
4 changes: 4 additions & 0 deletions packages/host/app/components/ai-assistant/message/usage.gts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ export default class AiAssistantMessageUsage extends Component {
@onInput={{fn (mut this.formattedMessage)}}
@value={{this.formattedMessage}}
/>
<Args.Array
@name='attachedCards'
@description='Cards attached to the message in pill form.'
/>
<Args.String
@name='errorMessage'
@description='Error state message to display'
Expand Down
Loading

0 comments on commit 2aa6fd2

Please sign in to comment.