Skip to content

Commit dd3d939

Browse files
committed
Show ApplyButton's preparing state when a command is being prepared
1 parent 1c30048 commit dd3d939

File tree

11 files changed

+182
-62
lines changed

11 files changed

+182
-62
lines changed

packages/ai-bot/lib/matrix.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,11 @@ export async function sendMessageEvent(
8282
export async function sendCommandEvent(
8383
client: MatrixClient,
8484
roomId: string,
85+
messageBody: string,
8586
functionCall: FunctionToolCall,
8687
eventToUpdate: string | undefined,
8788
) {
88-
let messageObject = toMatrixMessageCommandContent(functionCall);
89+
let messageObject = toMatrixMessageCommandContent(functionCall, messageBody);
8990

9091
if (messageObject !== undefined) {
9192
return await sendMatrixEvent(
@@ -128,9 +129,10 @@ export async function sendErrorEvent(
128129

129130
export const toMatrixMessageCommandContent = (
130131
functionCall: FunctionToolCall,
132+
messageBody: string | undefined,
131133
): IContent | undefined => {
132134
let { arguments: payload } = functionCall;
133-
const body = payload['description'] || 'Issuing command';
135+
const body = messageBody || payload['description'] || 'Issuing command';
134136
let messageObject: IContent = {
135137
body: body,
136138
msgtype: APP_BOXEL_COMMAND_MSGTYPE,

packages/ai-bot/lib/responder.ts

+12-3
Original file line numberDiff line numberDiff line change
@@ -124,15 +124,24 @@ export class Responder {
124124
for (const toolCall of msg.tool_calls || []) {
125125
log.debug('[Room Timeline] Function call', toolCall);
126126
try {
127+
const functionToolCall = this.deserializeToolCall(toolCall);
128+
this.latestContent = [
129+
this.latestContent,
130+
functionToolCall.arguments['description'],
131+
]
132+
.filter(Boolean)
133+
.join('\n\n');
127134
let commandEventPromise = sendCommandEvent(
128135
this.client,
129136
this.roomId,
130-
this.deserializeToolCall(toolCall),
131-
this.initialMessageReplaced ? undefined : this.responseEventId,
137+
this.latestContent,
138+
functionToolCall,
139+
this.responseEventId,
132140
);
133141
this.messagePromises.push(commandEventPromise);
134142
await commandEventPromise;
135143
this.initialMessageReplaced = true;
144+
this.isStreamingFinished = true;
136145
} catch (error) {
137146
Sentry.captureException(error);
138147
this.initialMessageReplaced = true;
@@ -159,7 +168,7 @@ export class Responder {
159168
}
160169

161170
async finalize(finalContent: string | void | null | undefined) {
162-
if (finalContent) {
171+
if (finalContent && !this.isStreamingFinished) {
163172
this.latestContent = cleanContent(finalContent);
164173
this.isStreamingFinished = true;
165174
await this.sendMessageEventWithDebouncing();

packages/ai-bot/tests/responding-test.ts

+26-20
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ module('Responding', (hooks) => {
269269
);
270270
});
271271

272-
test('Sends tool call event separately when content is sent before tool call', async () => {
272+
test('Sends tool call event and adds to event when content is sent before tool call', async () => {
273273
const patchArgs = {
274274
description: 'A new thing',
275275
attributes: {
@@ -303,13 +303,37 @@ module('Responding', (hooks) => {
303303
assert.equal(
304304
sentEvents.length,
305305
3,
306-
'Thinking message, and tool call event should be sent',
306+
'Thinking message, and content, and tool call event should be sent',
307307
);
308308
assert.equal(
309309
sentEvents[0].content.body,
310310
thinkingMessage,
311311
'Thinking message should be sent first',
312312
);
313+
assert.notOk(
314+
sentEvents[0].content['m.relates_to'],
315+
'The tool call event should not replace any message',
316+
);
317+
assert.equal(
318+
sentEvents[1].content.body,
319+
'some content',
320+
'Content message should be sent next',
321+
);
322+
assert.strictEqual(
323+
sentEvents[1].content['m.relates_to']?.event_id,
324+
sentEvents[0].eventId,
325+
'The content event should replace the initial message',
326+
);
327+
assert.equal(
328+
sentEvents[2].content.body,
329+
'some content\n\nA new thing',
330+
'Content message plus function description should be sent next',
331+
);
332+
assert.strictEqual(
333+
sentEvents[2].content['m.relates_to']?.event_id,
334+
sentEvents[0].eventId,
335+
'The command event should replace the initial message',
336+
);
313337
assert.deepEqual(
314338
JSON.parse(sentEvents[2].content.data),
315339
{
@@ -332,24 +356,6 @@ module('Responding', (hooks) => {
332356
},
333357
'Tool call event should be sent with correct content',
334358
);
335-
assert.notOk(
336-
sentEvents[2].content['m.relates_to'],
337-
'The tool call event should not replace any message',
338-
);
339-
340-
assert.equal(
341-
sentEvents[1].content.body,
342-
'some content',
343-
'Content event should be sent',
344-
);
345-
assert.deepEqual(
346-
sentEvents[1].content['m.relates_to'],
347-
{
348-
rel_type: 'm.replace',
349-
event_id: '0',
350-
},
351-
'The content event should replace the thinking message',
352-
);
353359
});
354360

355361
test('Updates message type to command when tool call is in progress', async () => {

packages/host/app/components/ai-assistant/apply-button/index.gts

+13
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const AiAssistantApplyButton: TemplateOnlyComponent<Signature> = <template>
4444
@kind='secondary-dark'
4545
@size='small'
4646
class='apply-button'
47+
tabindex='-1'
4748
{{setCssVar boxel-button-text-color='var(--boxel-200)'}}
4849
data-test-apply-state='preparing'
4950
...attributes
@@ -93,6 +94,12 @@ const AiAssistantApplyButton: TemplateOnlyComponent<Signature> = <template>
9394
border: 0;
9495
min-width: 74px;
9596
}
97+
.state-indicator.preparing .apply-button:hover,
98+
.state-indicator.preparing .apply-button:focus {
99+
--boxel-button-color: inherit;
100+
filter: none;
101+
cursor: not-allowed;
102+
}
96103
97104
.state-indicator.preparing::before {
98105
content: '';
@@ -150,6 +157,12 @@ const AiAssistantApplyButton: TemplateOnlyComponent<Signature> = <template>
150157
background-color: var(--boxel-error-200);
151158
border-color: var(--boxel-error-200);
152159
}
160+
161+
@keyframes spin {
162+
to {
163+
transform: rotate(360deg);
164+
}
165+
}
153166
</style>
154167
</template>;
155168

packages/host/app/components/ai-assistant/message/index.gts

+6-1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ interface Signature {
4141
}) => void;
4242
errorMessage?: string;
4343
isPending?: boolean;
44+
isCommandMessage?: boolean;
4445
retryAction?: () => void;
4546
};
4647
Blocks: { default: [] };
@@ -170,7 +171,11 @@ export default class AiAssistantMessage extends Component<Signature> {
170171
</div>
171172
{{/if}}
172173

173-
<div class='content' data-test-ai-message-content>
174+
<div
175+
class='content'
176+
data-test-ai-message-content
177+
data-test-command-message={{@isCommandMessage}}
178+
>
174179
{{@formattedMessage}}
175180

176181
{{yield}}

packages/host/app/components/ai-assistant/message/usage.gts

+4
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export default class AiAssistantMessageUsage extends Component {
1515
@tracked datetime = new Date(2024, 0, 3, 12, 30);
1616
@tracked isFromAssistant = false;
1717
@tracked isStreaming = false;
18+
@tracked isCommandMessage = false;
1819
@tracked userId = 'johndoe:boxel.ai';
1920
@tracked errorMessage = '';
2021

@@ -63,6 +64,7 @@ export default class AiAssistantMessageUsage extends Component {
6364
@errorMessage={{this.errorMessage}}
6465
@retryAction={{this.retryAction}}
6566
@isStreaming={{this.isStreaming}}
67+
@isCommandMessage={{this.isCommandMessage}}
6668
>
6769
<em>Optional embedded content</em>
6870
</AiAssistantMessage>
@@ -136,6 +138,7 @@ export default class AiAssistantMessageUsage extends Component {
136138
isReady=true
137139
}}
138140
@isStreaming={{false}}
141+
@isCommandMessage={{false}}
139142
/>
140143
<AiAssistantMessage
141144
@formattedMessage={{htmlSafe
@@ -146,6 +149,7 @@ export default class AiAssistantMessageUsage extends Component {
146149
@datetime={{this.oneMinutesAgo}}
147150
@isFromAssistant={{true}}
148151
@isStreaming={{false}}
152+
@isCommandMessage={{false}}
149153
/>
150154
</AiAssistantConversation>
151155
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { TemplateOnlyComponent } from '@ember/component/template-only';
2+
3+
import ApplyButton from '../ai-assistant/apply-button';
4+
5+
interface Signature {
6+
Element: HTMLDivElement;
7+
}
8+
9+
const RoomMessageCommand: TemplateOnlyComponent<Signature> = <template>
10+
<div ...attributes>
11+
<div class='command-button-bar'>
12+
<ApplyButton @state='preparing' data-test-command-apply='preparing' />
13+
</div>
14+
</div>
15+
16+
{{! template-lint-disable no-whitespace-for-layout }}
17+
{{! ignore the above error because ember-template-lint complains about the whitespace in the multi-line comment below }}
18+
<style scoped>
19+
.command-button-bar {
20+
display: flex;
21+
justify-content: flex-end;
22+
gap: var(--boxel-sp-xs);
23+
margin-top: var(--boxel-sp);
24+
}
25+
</style>
26+
</template>;
27+
28+
export default RoomMessageCommand;

packages/host/app/components/matrix/room-message.gts

+8-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { trackedFunction } from 'ember-resources/util/function';
1010

1111
import { Avatar } from '@cardstack/boxel-ui/components';
1212

13-
import { bool } from '@cardstack/boxel-ui/helpers';
13+
import { bool, or } from '@cardstack/boxel-ui/helpers';
1414

1515
import { markdownToHtml } from '@cardstack/runtime-common';
1616

@@ -25,6 +25,7 @@ import { type CardDef } from 'https://cardstack.com/base/card-api';
2525
import AiAssistantMessage from '../ai-assistant/message';
2626
import { aiBotUserId } from '../ai-assistant/panel';
2727

28+
import PreparingRoomMessageCommand from './preparing-room-message-command';
2829
import RoomMessageCommand from './room-message-command';
2930

3031
interface Signature {
@@ -119,6 +120,10 @@ export default class RoomMessage extends Component<Signature> {
119120
@isStreaming={{@isStreaming}}
120121
@retryAction={{if @message.command (perform this.run) @retryAction}}
121122
@isPending={{@isPending}}
123+
@isCommandMessage={{or
124+
(bool @message.command)
125+
@message.isPreparingCommand
126+
}}
122127
data-test-boxel-message-from={{@message.author.name}}
123128
data-test-boxel-message-instance-id={{@message.instanceId}}
124129
...attributes
@@ -137,6 +142,8 @@ export default class RoomMessage extends Component<Signature> {
137142
@failedCommandState={{this.failedCommandState}}
138143
@isError={{bool this.errorMessage}}
139144
/>
145+
{{else if @message.isPreparingCommand}}
146+
<PreparingRoomMessageCommand />
140147
{{/if}}
141148
</AiAssistantMessage>
142149
{{/if}}

packages/host/app/lib/matrix-classes/message-builder.ts

+38-34
Original file line numberDiff line numberDiff line change
@@ -129,13 +129,15 @@ export default class MessageBuilder {
129129
message.attachedCardIds = this.attachedCardIds;
130130
} else if (event.content.msgtype === 'm.text') {
131131
message.isStreamingFinished = !!event.content.isStreamingFinished; // Indicates whether streaming (message updating while AI bot is sending more content into the message) has finished
132-
} else if (
133-
event.content.msgtype === APP_BOXEL_COMMAND_MSGTYPE &&
134-
event.content.data.toolCall
135-
) {
132+
} else if (event.content.msgtype === APP_BOXEL_COMMAND_MSGTYPE) {
136133
message.formattedMessage = this.formattedMessageForCommand;
137-
message.command = this.buildMessageCommand(message);
138-
message.isStreamingFinished = true;
134+
const command = this.buildMessageCommand(message);
135+
if (command) {
136+
message.command = command;
137+
} else {
138+
message.isPreparingCommand = true;
139+
}
140+
message.isStreamingFinished = !!event.content.isStreamingFinished; // Indicates whether streaming (message updating while AI bot is sending more content into the message) has finished
139141
}
140142
return message;
141143
}
@@ -159,16 +161,16 @@ export default class MessageBuilder {
159161
message.updated = new Date();
160162

161163
if (this.event.content.msgtype === APP_BOXEL_COMMAND_MSGTYPE) {
162-
if (!message.command) {
163-
message.command = this.buildMessageCommand(message);
164+
const latestCommand = this.buildMessageCommand(message);
165+
if (!message.command && latestCommand) {
166+
message.command = latestCommand;
167+
message.isPreparingCommand = false;
168+
} else if (latestCommand && message.command) {
169+
message.command.name = latestCommand.name;
170+
message.command.payload = latestCommand.payload;
171+
} else {
172+
message.isPreparingCommand = true;
164173
}
165-
166-
message.isStreamingFinished = true;
167-
message.formattedMessage = this.formattedMessageForCommand;
168-
169-
let command = this.event.content.data.toolCall;
170-
message.command.name = command.name;
171-
message.command.payload = command.arguments;
172174
}
173175
}
174176

@@ -177,7 +179,7 @@ export default class MessageBuilder {
177179
message.command = this.buildMessageCommand(message);
178180
}
179181

180-
if (this.builderContext.commandResultEvent) {
182+
if (this.builderContext.commandResultEvent && message.command) {
181183
let event = this.builderContext.commandResultEvent;
182184
message.command.commandStatus = event.content['m.relates_to']
183185
.key as CommandStatus;
@@ -197,27 +199,29 @@ export default class MessageBuilder {
197199
return (
198200
e.type === APP_BOXEL_COMMAND_RESULT_EVENT_TYPE &&
199201
r.rel_type === 'm.annotation' &&
200-
(r.event_id === event!.content.data.eventId ||
201-
r.event_id === event!.event_id ||
202+
(r.event_id === event!.event_id ||
202203
r.event_id === this.builderContext.effectiveEventId)
203204
);
204205
}) as CommandResultEvent | undefined);
205206

206-
let command = event.content.data.toolCall;
207-
let messageCommand = new MessageCommand(
208-
message,
209-
command.id,
210-
command.name,
211-
command.arguments,
212-
this.builderContext.effectiveEventId,
213-
(commandResultEvent?.content['m.relates_to']?.key ||
214-
'ready') as CommandStatus,
215-
commandResultEvent?.content.msgtype ===
216-
APP_BOXEL_COMMAND_RESULT_WITH_OUTPUT_MSGTYPE
217-
? commandResultEvent.content.data.cardEventId
218-
: undefined,
219-
getOwner(this)!,
220-
);
221-
return messageCommand;
207+
if (event.content.isStreamingFinished !== false && event.content.data) {
208+
let command = event.content.data.toolCall;
209+
let messageCommand = new MessageCommand(
210+
message,
211+
command.id,
212+
command.name,
213+
command.arguments,
214+
this.builderContext.effectiveEventId,
215+
(commandResultEvent?.content['m.relates_to']?.key ||
216+
'ready') as CommandStatus,
217+
commandResultEvent?.content.msgtype ===
218+
APP_BOXEL_COMMAND_RESULT_WITH_OUTPUT_MSGTYPE
219+
? commandResultEvent.content.data.cardEventId
220+
: undefined,
221+
getOwner(this)!,
222+
);
223+
return messageCommand;
224+
}
225+
return null;
222226
}
223227
}

packages/host/app/lib/matrix-classes/message.ts

+1
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export class Message implements RoomMessageInterface {
4747
@tracked formattedMessage: string;
4848
@tracked message: string;
4949
@tracked command?: MessageCommand | null;
50+
@tracked isPreparingCommand?: boolean;
5051
@tracked isStreamingFinished?: boolean;
5152

5253
attachedCardIds?: string[] | null;

0 commit comments

Comments
 (0)