diff --git a/packages/host/app/components/ai-assistant/past-session-item.gts b/packages/host/app/components/ai-assistant/past-session-item.gts index 31337a5de7..65db0d6b89 100644 --- a/packages/host/app/components/ai-assistant/past-session-item.gts +++ b/packages/host/app/components/ai-assistant/past-session-item.gts @@ -206,10 +206,7 @@ export default class PastSessionItem extends Component { } get isStreaming() { - if (!this.args.session.lastMessage) { - return false; - } - return !this.args.session.lastMessage.isStreamingFinished; + return this.args.session.lastMessage?.isStreamingFinished === false; } get hasUnseenMessage() { diff --git a/packages/host/app/components/ai-assistant/past-sessions.gts b/packages/host/app/components/ai-assistant/past-sessions.gts index 3ed96cd8d3..f4317bfe3f 100644 --- a/packages/host/app/components/ai-assistant/past-sessions.gts +++ b/packages/host/app/components/ai-assistant/past-sessions.gts @@ -1,13 +1,18 @@ -import type { TemplateOnlyComponent } from '@ember/component/template-only'; import { on } from '@ember/modifier'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; -import { IconButton } from '@cardstack/boxel-ui/components'; +import { modifier } from 'ember-modifier'; + +import { IconButton, LoadingIndicator } from '@cardstack/boxel-ui/components'; import { DropdownArrowFilled } from '@cardstack/boxel-ui/icons'; import { SessionRoomData } from './panel'; import AiAssistantPanelPopover from './panel-popover'; import PastSessionItem, { type RoomActions } from './past-session-item'; +import type MatrixService from '../../services/matrix-service'; + interface Signature { Args: { sessions: SessionRoomData[]; @@ -17,51 +22,96 @@ interface Signature { Element: HTMLElement; } -const AiAssistantPastSessionsList: TemplateOnlyComponent = ; + checkScrollPosition(); -export default AiAssistantPastSessionsList; + element.addEventListener('scroll', checkScrollPosition); + + return () => { + element.removeEventListener('scroll', checkScrollPosition); + }; + }); + + +} diff --git a/packages/host/app/components/matrix/room.gts b/packages/host/app/components/matrix/room.gts index 4c7fd31e93..ec95775b5c 100644 --- a/packages/host/app/components/matrix/room.gts +++ b/packages/host/app/components/matrix/room.gts @@ -28,8 +28,8 @@ import { TrackedObject, TrackedSet } from 'tracked-built-ins'; import { v4 as uuidv4 } from 'uuid'; -import { BoxelButton } from '@cardstack/boxel-ui/components'; -import { eq, not } from '@cardstack/boxel-ui/helpers'; +import { BoxelButton, LoadingIndicator } from '@cardstack/boxel-ui/components'; +import { and, eq, not } from '@cardstack/boxel-ui/helpers'; import { type getCard, @@ -87,8 +87,14 @@ export default class Room extends Component { {{#if (not this.doMatrixEventFlush.isRunning)}}
@@ -109,7 +115,14 @@ export default class Room extends Component { data-test-message-idx={{i}} /> {{else}} - + {{#if this.matrixService.isLoadingTimeline}} + + {{else}} + + {{/if}} {{/each}} {{#if this.room}} {{#if this.showUnreadIndicator}} @@ -218,6 +231,12 @@ export default class Room extends Component { :deep(.ai-assistant-conversation > *:nth-last-of-type(2)) { padding-bottom: var(--boxel-sp-xl); } + .loading-indicator { + margin-top: auto; + margin-bottom: auto; + margin-left: auto; + margin-right: auto; + } @@ -805,7 +824,8 @@ export default class Room extends Component { this.autoAttachedCards.size !== 0, ) && !!this.room && - !this.messages.some((m) => this.isPendingMessage(m)) + !this.messages.some((m) => this.isPendingMessage(m)) && + !this.matrixService.isLoadingTimeline ); } diff --git a/packages/host/app/services/matrix-sdk-loader.ts b/packages/host/app/services/matrix-sdk-loader.ts index c990d08559..3fe9ad8a9b 100644 --- a/packages/host/app/services/matrix-sdk-loader.ts +++ b/packages/host/app/services/matrix-sdk-loader.ts @@ -3,6 +3,7 @@ import Service from '@ember/service'; import { service } from '@ember/service'; import * as MatrixSDK from 'matrix-js-sdk'; +import { SlidingSync } from 'matrix-js-sdk/lib/sliding-sync'; import { RealmAuthClient } from '@cardstack/runtime-common/realm-auth-client'; @@ -23,6 +24,11 @@ export default class MatrixSDKLoader extends Service { } return this.#extended; } + + // For testing purposes, we need to mock the SlidingSync class + get SlidingSync() { + return SlidingSync; + } } export class ExtendedMatrixSDK { diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index b7db6731c0..e53497a68a 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -4,7 +4,7 @@ import { debounce } from '@ember/runloop'; import Service, { service } from '@ember/service'; import { cached, tracked } from '@glimmer/tracking'; -import { task } from 'ember-concurrency'; +import { task, dropTask } from 'ember-concurrency'; import window from 'ember-window-mock'; import { cloneDeep } from 'lodash'; import { @@ -14,6 +14,13 @@ import { type EmittedEvents, type ISendEventResponse, } from 'matrix-js-sdk'; +import { + type SlidingSync, + type MSC3575List, + SlidingSyncEvent, + SlidingSyncState, + type MSC3575SlidingSyncResponse, +} from 'matrix-js-sdk/lib/sliding-sync'; import stringify from 'safe-stable-stringify'; import { md5 } from 'super-fast-md5'; import { TrackedMap } from 'tracked-built-ins'; @@ -115,6 +122,10 @@ import type * as MatrixSDK from 'matrix-js-sdk'; const { matrixURL } = ENV; const MAX_CARD_SIZE_KB = 60; const STATE_EVENTS_OF_INTEREST = ['m.room.create', 'm.room.name']; +const SLIDING_SYNC_AI_ROOM_LIST_NAME = 'ai-room'; +const SLIDING_SYNC_AUTH_ROOM_LIST_NAME = 'auth-room'; +const SLIDING_SYNC_LIST_RANGE_END = 9; +const SLIDING_SYNC_LIST_TIMELINE_LIMIT = 1; const realmEventsLogger = logger('realm:events'); @@ -139,6 +150,8 @@ export default class MatrixService extends Service { @tracked private _isNewUser = false; @tracked private postLoginCompleted = false; @tracked private _currentRoomId: string | undefined; + @tracked private timelineLoadingState: Map = + new TrackedMap(); profile = getMatrixProfile(this, () => this.userId); @@ -166,6 +179,10 @@ export default class MatrixService extends Service { private cardHashes: Map = new Map(); // hashes <> event id private skillCardHashes: Map = new Map(); // hashes <> event id + private slidingSync: SlidingSync | undefined; + private aiRoomIds: Set = new Set(); + @tracked private _isLoadingMoreAIRooms = false; + constructor(owner: Owner) { super(owner); this.#ready = this.loadState.perform(); @@ -182,6 +199,7 @@ export default class MatrixService extends Service { set currentRoomId(value: string | undefined) { this._currentRoomId = value; if (value) { + this.loadAllTimelineEvents.perform(value); window.localStorage.setItem(CurrentRoomIdPersistenceKey, value); } else { window.localStorage.removeItem(CurrentRoomIdPersistenceKey); @@ -233,6 +251,7 @@ export default class MatrixService extends Service { e.event.content.realms, ); await this.loginToRealms(); + await this.loadMoreAuthRooms(e.event.content.realms); } }, ], @@ -486,7 +505,8 @@ export default class MatrixService extends Service { if (this.startedAtTs === -1) { this.startedAtTs = 0; } - await this._client.startClient(); + await this.initSlidingSync(); + await this.client.startClient({ slidingSync: this.slidingSync }); let accountDataContent = await this._client.getAccountDataFromServer<{ realms: string[]; }>(APP_BOXEL_REALMS_EVENT_TYPE); @@ -509,6 +529,66 @@ export default class MatrixService extends Service { } } + private async initSlidingSync() { + let accountData = await this.client.getAccountDataFromServer<{ + realms: string[]; + }>(APP_BOXEL_REALMS_EVENT_TYPE); + + let lists: Map = new Map(); + lists.set(SLIDING_SYNC_AI_ROOM_LIST_NAME, { + ranges: [[0, SLIDING_SYNC_LIST_RANGE_END]], + filters: { + is_dm: false, + }, + timeline_limit: SLIDING_SYNC_LIST_TIMELINE_LIMIT, + required_state: [['*', '*']], + }); + lists.set(SLIDING_SYNC_AUTH_ROOM_LIST_NAME, { + ranges: [ + [ + 0, + accountData + ? accountData?.realms.length + : SLIDING_SYNC_LIST_RANGE_END, + ], + ], + filters: { + is_dm: true, + }, + timeline_limit: SLIDING_SYNC_LIST_TIMELINE_LIMIT, + required_state: [['*', '*']], + }); + this.slidingSync = new this.matrixSdkLoader.SlidingSync( + this.client.baseUrl, + lists, + { + timeline_limit: SLIDING_SYNC_LIST_TIMELINE_LIMIT, + }, + this.client as any, + 500, + ); + this.slidingSync.on( + SlidingSyncEvent.Lifecycle, + ( + state: SlidingSyncState | null, + resp: MSC3575SlidingSyncResponse | null, + ) => { + if ( + state === SlidingSyncState.Complete && + resp && + resp.lists[SLIDING_SYNC_AI_ROOM_LIST_NAME].ops?.[0]?.op === 'SYNC' + ) { + for (let roomId of resp.lists[SLIDING_SYNC_AI_ROOM_LIST_NAME].ops[0] + .room_ids) { + this.aiRoomIds.add(roomId); + } + } + }, + ); + + return this.slidingSync; + } + private async loginToRealms() { // This is where we would actually load user-specific choices out of the // user's profile based on this.client.getUserId(); @@ -545,7 +625,7 @@ export default class MatrixService extends Service { | CommandResultWithOutputContent | CommandDefinitionsContent, ) { - let roomData = await this.ensureRoomData(roomId); + let roomData = this.ensureRoomData(roomId); return roomData.mutex.dispatch(async () => { if ('data' in content) { const encodedContent = { @@ -1021,7 +1101,7 @@ export default class MatrixService extends Service { } async setPowerLevel(roomId: string, userId: string, powerLevel: number) { - let roomData = await this.ensureRoomData(roomId); + let roomData = this.ensureRoomData(roomId); await roomData.mutex.dispatch(async () => { return this.client.setPowerLevel(roomId, userId, powerLevel); }); @@ -1058,7 +1138,7 @@ export default class MatrixService extends Service { content: Record, stateKey: string = '', ) { - let roomData = await this.ensureRoomData(roomId); + let roomData = this.ensureRoomData(roomId); await roomData.mutex.dispatch(async () => { return this.client.sendStateEvent(roomId, eventType, content, stateKey); }); @@ -1072,7 +1152,7 @@ export default class MatrixService extends Service { content: Record, ) => Promise>, ) { - let roomData = await this.ensureRoomData(roomId); + let roomData = this.ensureRoomData(roomId); await roomData.mutex.dispatch(async () => { let currentContent = await this.getStateEventSafe( roomId, @@ -1090,21 +1170,21 @@ export default class MatrixService extends Service { } async leave(roomId: string) { - let roomData = await this.ensureRoomData(roomId); + let roomData = this.ensureRoomData(roomId); await roomData.mutex.dispatch(async () => { return this.client.leave(roomId); }); } async forget(roomId: string) { - let roomData = await this.ensureRoomData(roomId); + let roomData = this.ensureRoomData(roomId); await roomData.mutex.dispatch(async () => { return this.client.forget(roomId); }); } async setRoomName(roomId: string, name: string) { - let roomData = await this.ensureRoomData(roomId); + let roomData = this.ensureRoomData(roomId); await roomData.mutex.dispatch(async () => { return this.client.setRoomName(roomId, name); }); @@ -1144,11 +1224,45 @@ export default class MatrixService extends Service { return await this.client.isUsernameAvailable(username); } - async getRoomState(roomId: string) { - return this.client - .getRoom(roomId) - ?.getLiveTimeline() - .getState('f' as MatrixSDK.Direction); + private loadAllTimelineEvents = dropTask(async (roomId: string) => { + let roomData = this.ensureRoomData(roomId); + let room = this.client.getRoom(roomId); + let roomResource = this.roomResources.get(roomId); + + if (!room || !roomResource) { + throw new Error(`Cannot find room with id ${roomId}`); + } + + if (this.timelineLoadingState.get(roomId)) { + return; + } + + this.timelineLoadingState.set(roomId, true); + try { + while (room.oldState.paginationToken != null) { + await this.client.scrollback(room); + let rs = room.getLiveTimeline().getState('f' as MatrixSDK.Direction); + if (rs) { + roomData.notifyRoomStateUpdated(rs); + } + } + + // Wait for all events to be loaded in roomResource + let timeline = room.getLiveTimeline(); + let events = timeline.getEvents(); + this.timelineQueue.push(...events.map((e) => ({ event: e }))); + await this.drainTimeline(); + await this.roomResources.get(roomId)?.processing; + } finally { + this.timelineLoadingState.set(roomId, false); + } + }); + + get isLoadingTimeline() { + if (!this.currentRoomId) { + return false; + } + return this.timelineLoadingState.get(this.currentRoomId) ?? false; } async sendActiveLLMEvent(roomId: string, model: string) { @@ -1165,18 +1279,14 @@ export default class MatrixService extends Service { `bug: roomId is undefined for event ${JSON.stringify(event, null, 2)}`, ); } - let roomData = await this.ensureRoomData(roomId); + let roomData = this.ensureRoomData(roomId); roomData.addEvent(event, oldEventId); } - private async ensureRoomData(roomId: string) { + private ensureRoomData(roomId: string) { let roomData = this.getRoomData(roomId); if (!roomData) { roomData = new Room(roomId); - let rs = await this.getRoomState(roomId); - if (rs) { - roomData.notifyRoomStateUpdated(rs); - } this.setRoomData(roomId, roomData); } return roomData; @@ -1295,7 +1405,7 @@ export default class MatrixService extends Service { } roomStates = Array.from(roomStateMap.values()); for (let rs of roomStates) { - let roomData = await this.ensureRoomData(rs.roomId); + let roomData = this.ensureRoomData(rs.roomId); roomData.notifyRoomStateUpdated(rs); } roomStateUpdatesDrained!(); @@ -1421,7 +1531,7 @@ export default class MatrixService extends Service { return; } - let roomData = await this.getRoomData(roomId); + let roomData = this.getRoomData(roomId); // patch in any missing room events--this will support dealing with local // echoes, migrating older histories as well as handle any matrix syncing gaps // that might occur @@ -1508,11 +1618,6 @@ export default class MatrixService extends Service { ) { this.commandService.queueEventForCommandProcessing(event); } - - if (room.oldState.paginationToken != null) { - // we need to scroll back to capture any room events fired before this one - await this.client?.scrollback(room); - } } async activateCodingSkill() { @@ -1546,6 +1651,69 @@ export default class MatrixService extends Service { } roomResource.activateLLMTask.perform(model); } + + loadMoreAIRooms() { + this.loadMoreAIRoomsTask.perform(); + } + + private loadMoreAIRoomsTask = dropTask(async () => { + if (!this.slidingSync) { + throw new Error( + 'To load more AI rooms, sliding sync must be initialized', + ); + } + + let currentList = this.slidingSync.getListParams( + SLIDING_SYNC_AI_ROOM_LIST_NAME, + ); + if (!currentList) return; + + let currentRange = currentList.ranges[0]; + if (!currentRange) return; + + if (this.aiRoomIds.size < currentRange[1] - 1) { + return; + } + + let newEndRange = currentRange[1] + 10; + + this._isLoadingMoreAIRooms = true; + try { + await this.slidingSync.setListRanges(SLIDING_SYNC_AI_ROOM_LIST_NAME, [ + [0, newEndRange], + ]); + } finally { + this._isLoadingMoreAIRooms = false; + } + }); + + get isLoadingMoreAIRooms() { + return this._isLoadingMoreAIRooms; + } + + async loadMoreAuthRooms(realms: string[]) { + if (!this.slidingSync) { + throw new Error( + 'To load more auth rooms, sliding sync must be initialized', + ); + } + + let currentList = this.slidingSync.getListParams( + SLIDING_SYNC_AUTH_ROOM_LIST_NAME, + ); + if (!currentList) return; + + let currentRange = currentList.ranges[0]; + if (!currentRange) return; + if (realms.length - 1 <= currentRange[1]) { + return; + } + + let newEndRange = realms.length - 1; + await this.slidingSync.setListRanges(SLIDING_SYNC_AUTH_ROOM_LIST_NAME, [ + [0, newEndRange], + ]); + } } function saveAuth(auth: LoginResponse) { diff --git a/packages/host/ember-cli-build.js b/packages/host/ember-cli-build.js index a3443bb163..857c975c13 100644 --- a/packages/host/ember-cli-build.js +++ b/packages/host/ember-cli-build.js @@ -86,7 +86,7 @@ module.exports = function (defaults) { process: false, }, alias: { - 'matrix-js-sdk': 'matrix-js-sdk/src/browser-index.ts', // Consume matrix-js-sdk via Typescript ESM so that code splitting works to exlcude massive matrix-sdk-crypto-wasm from the main bundle + 'matrix-js-sdk$': 'matrix-js-sdk/src/browser-index.ts', // Consume matrix-js-sdk via Typescript ESM so that code splitting works to exlcude massive matrix-sdk-crypto-wasm from the main bundle }, }, node: { diff --git a/packages/host/tests/acceptance/ai-assistant-test.gts b/packages/host/tests/acceptance/ai-assistant-test.gts index 4a5762ebb0..e0fba4e3ee 100644 --- a/packages/host/tests/acceptance/ai-assistant-test.gts +++ b/packages/host/tests/acceptance/ai-assistant-test.gts @@ -1,4 +1,4 @@ -import { click, fillIn, waitFor } from '@ember/test-helpers'; +import { click, fillIn, waitFor, waitUntil } from '@ember/test-helpers'; import { module, test } from 'qunit'; @@ -35,6 +35,7 @@ import { } from '../helpers/base-realm'; import { setupMockMatrix } from '../helpers/mock-matrix'; +import { getRoomIdForRealmAndUser } from '../helpers/mock-matrix/_utils'; import { setupApplicationTest } from '../helpers/setup'; async function selectCardFromCatalog(cardId: string) { @@ -52,6 +53,10 @@ module('Acceptance | AI Assistant tests', function (hooks) { let mockMatrixUtils = setupMockMatrix(hooks, { loggedInAs: '@testuser:localhost', activeRealms: [baseRealm.url, testRealmURL], + directRooms: [ + getRoomIdForRealmAndUser(testRealmURL, '@testuser:localhost'), + getRoomIdForRealmAndUser(baseRealm.url, '@testuser:localhost'), + ], }); let { createAndJoinRoom, getRoomState } = mockMatrixUtils; @@ -183,6 +188,7 @@ module('Acceptance | AI Assistant tests', function (hooks) { ], }); await click('[data-test-open-ai-assistant]'); + await waitFor(`[data-room-settled]`); const testCard = `${testRealmURL}Person/hassan`; for (let i = 1; i <= 3; i++) { @@ -260,7 +266,7 @@ module('Acceptance | AI Assistant tests', function (hooks) { ], }); await click('[data-test-open-ai-assistant]'); - + await waitFor(`[data-room-settled]`); assert .dom('[data-test-llm-select-selected]') .hasText(DEFAULT_LLM.split('/')[1]); @@ -275,7 +281,7 @@ module('Acceptance | AI Assistant tests', function (hooks) { await click('[data-test-llm-select-item="google/gemini-pro-1.5"]'); assert.dom('[data-test-llm-select-selected]').hasText('gemini-pro-1.5'); - let roomState = getRoomState('mock_room_1', APP_BOXEL_ACTIVE_LLM, ''); + let roomState = getRoomState(matrixRoomId, APP_BOXEL_ACTIVE_LLM, ''); assert.strictEqual(roomState.model, 'google/gemini-pro-1.5'); }); @@ -291,9 +297,10 @@ module('Acceptance | AI Assistant tests', function (hooks) { ], }); + await click('[data-test-open-ai-assistant]'); + await waitFor(`[data-room-settled]`); await click('[data-test-submode-switcher] button'); await click('[data-test-boxel-menu-item-text="Code"]'); - await click('[data-test-open-ai-assistant]'); assert.dom('[data-test-llm-select-selected]').hasText('claude-3.5-sonnet'); createAndJoinRoom({ @@ -302,8 +309,8 @@ module('Acceptance | AI Assistant tests', function (hooks) { }); await click('[data-test-past-sessions-button]'); - await waitFor("[data-test-enter-room='mock_room_2']"); - await click('[data-test-enter-room="mock_room_2"]'); + await waitFor("[data-test-enter-room='mock_room_1']"); + await click('[data-test-enter-room="mock_room_1"]'); assert.dom('[data-test-llm-select-selected]').hasText('claude-3.5-sonnet'); }); @@ -325,6 +332,7 @@ module('Acceptance | AI Assistant tests', function (hooks) { '[data-test-cards-grid-item="http://test-realm/test/Person/fadhlan"]', ); await click('[data-test-open-ai-assistant]'); + await waitFor(`[data-room-settled]`); assert.dom('[data-test-autoattached-file]').doesNotExist(); assert.dom('[data-test-autoattached-card]').exists(); await click('[data-test-submode-switcher] > [data-test-boxel-button]'); @@ -347,6 +355,7 @@ module('Acceptance | AI Assistant tests', function (hooks) { }); await click('[data-test-open-ai-assistant]'); + await waitFor(`[data-room-settled]`); assert.dom('[data-test-choose-file-btn]').hasText('Attach File'); await click('[data-test-choose-file-btn]'); @@ -411,6 +420,7 @@ module('Acceptance | AI Assistant tests', function (hooks) { ], }); await click('[data-test-open-ai-assistant]'); + await waitFor(`[data-room-settled]`); assert.dom('[data-test-choose-file-btn]').hasText('Attach File'); await click('[data-test-file="person.gts"]'); @@ -431,4 +441,42 @@ module('Acceptance | AI Assistant tests', function (hooks) { assert.dom('[data-test-autoattached-file]').exists(); assert.dom(`[data-test-autoattached-file]`).hasText('person.gts'); }); + + test('loads more AI rooms when scrolling', async function (assert) { + for (let i = 1; i <= 15; i++) { + createAndJoinRoom({ + sender: '@testuser:localhost', + name: `AI Room ${i}`, + }); + } + + await visitOperatorMode({ + stacks: [ + [ + { + id: `${testRealmURL}index`, + format: 'isolated', + }, + ], + ], + }); + + await click('[data-test-open-ai-assistant]'); + await waitFor(`[data-room-settled]`); + await click('[data-test-past-sessions-button]'); + + assert.dom('[data-test-past-sessions]').exists(); + assert.dom('[data-test-joined-room]').exists({ count: 10 }); + + let pastSessionsElement = document.querySelector( + '[data-test-past-sessions] .body ul', + ); + if (pastSessionsElement) { + pastSessionsElement.scrollTop = pastSessionsElement.scrollHeight; + } + await waitUntil( + () => document.querySelectorAll('[data-test-joined-room]').length === 16, + ); + assert.dom('[data-test-joined-room]').exists({ count: 16 }); + }); }); diff --git a/packages/host/tests/acceptance/commands-test.gts b/packages/host/tests/acceptance/commands-test.gts index 3ddfde37f8..040027cb15 100644 --- a/packages/host/tests/acceptance/commands-test.gts +++ b/packages/host/tests/acceptance/commands-test.gts @@ -10,6 +10,8 @@ import { settled, } from '@ember/test-helpers'; +import { fillIn } from '@ember/test-helpers'; + import { module, test } from 'qunit'; import { GridContainer } from '@cardstack/boxel-ui/components'; @@ -317,7 +319,7 @@ module('Acceptance | Commands tests', function (hooks) { ); await sendAiAssistantMessageCommand.execute({ prompt: 'Please delay', - roomId: 'mock_room_1', + roomId: getRoomIds().pop()!, commands: [{ command: sleepCommand, autoExecute: true }], }); await sleepCommand.execute(new ScheduleMeetingInput()); @@ -359,7 +361,7 @@ module('Acceptance | Commands tests', function (hooks) { commandContext, ); await openAiAssistantRoomCommand.execute({ - roomId: 'mock_room_1', + roomId: getRoomIds().pop()!, }); }; @@ -681,8 +683,8 @@ module('Acceptance | Commands tests', function (hooks) { `[data-test-stack-card="${testRealmURL}index"] [data-test-cards-grid-item="${testCard}"]`, ); await click('[data-test-delay-button]'); - await waitUntil(() => getRoomIds().includes('mock_room_1')); - let roomId = 'mock_room_1'; + await waitUntil(() => getRoomIds().length > 0); + let roomId = getRoomIds().pop()!; let message = getRoomEvents(roomId).pop()!; let boxelMessageData = JSON.parse(message.content.data); let toolName = boxelMessageData.context.tools[0].function.name; @@ -1104,11 +1106,20 @@ module('Acceptance | Commands tests', function (hooks) { ], ], }); + let roomId = getRoomIds().pop()!; // open assistant, ShowCard command is part of default CardEditing skill await click('[data-test-open-ai-assistant]'); + // Need to create a new room so this new room will include skills card + await fillIn( + '[data-test-message-field]', + 'Test message to enable new session button', + ); + await click('[data-test-send-message-btn]'); + await click('[data-test-create-room-btn]'); + // simulate message - let roomId = getRoomIds().pop()!; + roomId = getRoomIds().pop()!; simulateRemoteMessage(roomId, '@aibot:localhost', { body: 'Show the card', msgtype: APP_BOXEL_MESSAGE_MSGTYPE, diff --git a/packages/host/tests/helpers/mock-matrix.ts b/packages/host/tests/helpers/mock-matrix.ts index 10c1d028c4..4633e417b7 100644 --- a/packages/host/tests/helpers/mock-matrix.ts +++ b/packages/host/tests/helpers/mock-matrix.ts @@ -6,6 +6,7 @@ import type MatrixService from '@cardstack/host/services/matrix-service'; import MessageService from '@cardstack/host/services/message-service'; import { MockSDK } from './mock-matrix/_sdk'; +import { MockSlidingSync } from './mock-matrix/_sliding-sync'; import { MockUtils } from './mock-matrix/_utils'; export interface Config { @@ -16,13 +17,18 @@ export interface Config { expiresInSec?: number; autostart?: boolean; now?: () => number; + directRooms?: string[]; } export function setupMockMatrix( hooks: NestedHooks, opts: Config = {}, ): MockUtils { - let testState: { owner?: Owner; sdk?: MockSDK; opts?: Config } = { + let testState: { + owner?: Owner; + sdk?: MockSDK; + opts?: Config; + } = { owner: undefined, sdk: undefined, opts: undefined, @@ -76,6 +82,7 @@ export function setupMockMatrix( async load() { return sdk; }, + SlidingSync: MockSlidingSync, }, { instantiate: false, diff --git a/packages/host/tests/helpers/mock-matrix/_client.ts b/packages/host/tests/helpers/mock-matrix/_client.ts index e2430e3f3a..a951143ba9 100644 --- a/packages/host/tests/helpers/mock-matrix/_client.ts +++ b/packages/host/tests/helpers/mock-matrix/_client.ts @@ -2,6 +2,11 @@ import { MatrixEvent } from 'matrix-js-sdk'; import * as MatrixSDK from 'matrix-js-sdk'; +import { + MSC3575SlidingSyncRequest, + MSC3575SlidingSyncResponse, +} from 'matrix-js-sdk/lib/sliding-sync'; + import { baseRealm, unixTime } from '@cardstack/runtime-common'; import { @@ -47,9 +52,16 @@ export class MockClient implements ExtendedClient { async getAccountDataFromServer( _eventType: string, ): Promise { - return { - realms: this.sdkOpts.activeRealms ?? [], - } as unknown as T; + if (_eventType === 'm.direct') { + return { + [this.loggedInAs!]: this.sdkOpts.directRooms ?? [], + } as unknown as T; + } else if (_eventType === APP_BOXEL_REALMS_EVENT_TYPE) { + return { + realms: this.sdkOpts.activeRealms ?? [], + } as unknown as T; + } + return null; } get loggedInAs() { @@ -57,8 +69,12 @@ export class MockClient implements ExtendedClient { } async startClient( - _opts?: MatrixSDK.IStartClientOpts | undefined, + opts?: MatrixSDK.IStartClientOpts | undefined, ): Promise { + if (opts?.slidingSync) { + await opts.slidingSync.start(); + } + this.serverState.onEvent((serverEvent: IEvent) => { this.emitEvent(new MatrixEvent(serverEvent)); }); @@ -96,6 +112,7 @@ export class MockClient implements ExtendedClient { nonce: nonce++, permissions, }; + let stringifiedHeader = JSON.stringify(header); let stringifiedPayload = JSON.stringify(payload); let headerAndPayload = `${btoa(stringifiedHeader)}.${btoa( @@ -121,6 +138,8 @@ export class MockClient implements ExtendedClient { setAccountData(type: string, data: T): Promise<{}> { if (type === APP_BOXEL_REALMS_EVENT_TYPE) { this.sdkOpts.activeRealms = (data as any).realms; + } else if (type === 'm.direct') { + this.sdkOpts.directRooms = (data as any)[this.loggedInAs!]; } else { throw new Error( 'Support for updating this event type in account data is not yet implemented in this mock.', @@ -399,6 +418,10 @@ export class MockClient implements ExtendedClient { return { getState: (_direction: MatrixSDK.Direction) => this.serverState.getRoomStateUpdatePayload(roomId!), + getEvents: () => + this.serverState + .getRoomEvents(roomId!) + .map((e) => new MatrixEvent(e)), }; }, } as MatrixSDK.Room; @@ -600,6 +623,70 @@ export class MockClient implements ExtendedClient { return mxcUrl.replace('mxc://', 'http://mock-server/'); } + async slidingSync( + req: MSC3575SlidingSyncRequest, + _proxyBaseUrl: string, + _signal: AbortSignal, + ): Promise { + let lists: MSC3575SlidingSyncResponse['lists'] = {}; + let rooms: MSC3575SlidingSyncResponse['rooms'] = {}; + for (const [listKey, list] of Object.entries(req.lists || {})) { + for (let i = 0; i < list.ranges.length; i++) { + let [start, end] = list.ranges[i]; + //currently we only filter rooms using is_dm + let dmRooms = (await this.getAccountDataFromServer('m.direct')) ?? {}; + let roomsInRange = this.serverState.rooms + .filter((r) => + list.filters?.is_dm + ? dmRooms[this.loggedInAs!]?.includes(r.id) + : !dmRooms[this.loggedInAs!]?.includes(r.id), + ) + .slice(start, end + 1); + + for (let j = 0; j < roomsInRange.length; j++) { + let room = roomsInRange[j]; + let timeline = this.serverState.getRoomEvents(room.id); + rooms[room.id] = { + name: + this.serverState.getRoomState(room.id, 'm.room.name', '')?.content + ?.name ?? 'room', + required_state: [], + timeline, + notification_count: 0, + highlight_count: 0, + joined_count: 1, + invited_count: 0, + initial: true, + }; + for (let k = 0; k < timeline.length; k++) { + let event = timeline[k]; + this.emitEvent(new MatrixEvent(event)); + } + } + + lists[listKey] = { + count: roomsInRange.length, + ops: [ + { + op: 'SYNC', + range: [start, end], + room_ids: roomsInRange.map((r) => r.id), + }, + ], + }; + } + } + + let response: MSC3575SlidingSyncResponse = { + pos: String(Date.now()), + lists, + rooms, + extensions: {}, + }; + + return Promise.resolve(response); + } + getDeviceId(): string | null { return null; } diff --git a/packages/host/tests/helpers/mock-matrix/_server-state.ts b/packages/host/tests/helpers/mock-matrix/_server-state.ts index cc0024e53b..dd398ce226 100644 --- a/packages/host/tests/helpers/mock-matrix/_server-state.ts +++ b/packages/host/tests/helpers/mock-matrix/_server-state.ts @@ -98,39 +98,41 @@ export class ServerState { { origin_server_ts: timestamp, state_key: sender }, ); - this.addRoomEvent( - sender, - { - room_id: roomId, - type: 'm.room.member', - content: { - displayname: 'aibot', - membership: 'invite', + if (!roomId.includes('test-session-room-realm')) { + this.addRoomEvent( + sender, + { + room_id: roomId, + type: 'm.room.member', + content: { + displayname: 'aibot', + membership: 'invite', + }, }, - }, - { - origin_server_ts: timestamp, - // host application expects this for the bot to join the room - state_key: '@aibot:localhost', - }, - ); + { + origin_server_ts: timestamp, + // host application expects this for the bot to join the room + state_key: '@aibot:localhost', + }, + ); - this.addRoomEvent( - '@aibot:localhost', - { - room_id: roomId, - type: 'm.room.member', - content: { - displayname: 'aibot', - membership: 'join', + this.addRoomEvent( + '@aibot:localhost', + { + room_id: roomId, + type: 'm.room.member', + content: { + displayname: 'aibot', + membership: 'join', + }, }, - }, - { - origin_server_ts: timestamp, - // host application expects this for the bot to join the room - state_key: '@aibot:localhost', - }, - ); + { + origin_server_ts: timestamp, + // host application expects this for the bot to join the room + state_key: '@aibot:localhost', + }, + ); + } return roomId; } diff --git a/packages/host/tests/helpers/mock-matrix/_sliding-sync.ts b/packages/host/tests/helpers/mock-matrix/_sliding-sync.ts new file mode 100644 index 0000000000..7af6bec014 --- /dev/null +++ b/packages/host/tests/helpers/mock-matrix/_sliding-sync.ts @@ -0,0 +1,77 @@ +import * as MatrixSDK from 'matrix-js-sdk'; +import { + SlidingSync, + SlidingSyncEvent, + SlidingSyncState, + MSC3575List, + MSC3575RoomSubscription, +} from 'matrix-js-sdk/lib/sliding-sync'; + +export class MockSlidingSync extends SlidingSync { + private _client: MatrixSDK.MatrixClient; + private _lists: Record; + private lifecycleCallbacks: Function[] = []; + private listCallbacks: Function[] = []; + + constructor( + proxyBaseUrl: string, + lists: Map, + roomSubscriptionInfo: MSC3575RoomSubscription, + client: MatrixSDK.MatrixClient, + timeoutMS: number, + ) { + super(proxyBaseUrl, lists, roomSubscriptionInfo, client, timeoutMS); + this._client = client; + this._lists = Object.fromEntries(lists); + } + + on(event: string, callback: Function) { + if (event === SlidingSyncEvent.Lifecycle) { + this.lifecycleCallbacks.push(callback); + } + if (event === SlidingSyncEvent.List) { + this.listCallbacks.push(callback); + } + return this; + } + + emit(event: string, ...args: any[]) { + if (event === SlidingSyncEvent.Lifecycle) { + this.lifecycleCallbacks.forEach((cb) => cb(...args)); + } + if (event === SlidingSyncEvent.List) { + this.listCallbacks.forEach((cb) => cb(...args)); + } + return true; + } + + async start() { + if (!this._lists) { + return; + } + let slidingResponse = await this._client.slidingSync( + { + lists: this._lists, + room_subscriptions: undefined, + }, + '', + {} as any, + ); + + this.emit( + SlidingSyncEvent.Lifecycle, + SlidingSyncState.Complete, + slidingResponse, + ); + } + + async setListRanges(listKey: string, ranges: number[][]) { + this._lists[listKey].ranges = ranges; + return await this.resend(); + } + + async resend() { + await this.start(); + return Promise.resolve(''); + } +} diff --git a/packages/host/tests/helpers/mock-matrix/_utils.ts b/packages/host/tests/helpers/mock-matrix/_utils.ts index 39a8b7817f..f739109eb8 100644 --- a/packages/host/tests/helpers/mock-matrix/_utils.ts +++ b/packages/host/tests/helpers/mock-matrix/_utils.ts @@ -25,7 +25,7 @@ export class MockUtils { }; getRoomIdForRealmAndUser = (realmURL: string, userId: string) => { - return `test-session-room-realm-${realmURL}-user-${userId}`; + return getRoomIdForRealmAndUser(realmURL, userId); }; getRoomState = (roomId: string, eventType: string, stateKey?: string) => { @@ -109,3 +109,7 @@ export class MockUtils { function isRealmEvent(e: IEvent): e is RealmEvent { return e.type === APP_BOXEL_REALM_EVENT_TYPE; } + +export function getRoomIdForRealmAndUser(realmURL: string, userId: string) { + return `test-session-room-realm-${realmURL}-user-${userId}`; +} diff --git a/packages/matrix/docker/synapse/dev/homeserver.yaml b/packages/matrix/docker/synapse/dev/homeserver.yaml index 5c798c7b96..8db86fbcc4 100644 --- a/packages/matrix/docker/synapse/dev/homeserver.yaml +++ b/packages/matrix/docker/synapse/dev/homeserver.yaml @@ -99,3 +99,6 @@ email: templates: custom_template_directory: "/custom/templates/" + +experimental_features: + msc3575_enabled: true diff --git a/packages/matrix/docker/synapse/index.ts b/packages/matrix/docker/synapse/index.ts index b312778d15..b64c824828 100644 --- a/packages/matrix/docker/synapse/index.ts +++ b/packages/matrix/docker/synapse/index.ts @@ -126,7 +126,7 @@ export async function synapseStart( ); await dockerCreateNetwork({ networkName: 'boxel' }); const synapseId = await dockerRun({ - image: 'matrixdotorg/synapse:v1.99.0rc1', + image: 'matrixdotorg/synapse:v1.126.0', containerName: 'boxel-synapse', dockerParams: [ '--rm', diff --git a/packages/realm-server/node-realm.ts b/packages/realm-server/node-realm.ts index 3b821dbd22..5c4955adcb 100644 --- a/packages/realm-server/node-realm.ts +++ b/packages/realm-server/node-realm.ts @@ -224,7 +224,7 @@ export class NodeAdapter implements RealmAdapter { try { dmRooms = - (await matrixClient.getAccountData>( + (await matrixClient.getAccountDataFromServer>( 'boxel.session-rooms', )) ?? {}; } catch (e) { diff --git a/packages/realm-server/server.ts b/packages/realm-server/server.ts index cd74ddd78b..1c14b6e4e8 100644 --- a/packages/realm-server/server.ts +++ b/packages/realm-server/server.ts @@ -461,7 +461,7 @@ export class RealmServer { private sendEvent = async (user: string, eventType: string) => { let dmRooms = - (await this.matrixClient.getAccountData>( + (await this.matrixClient.getAccountDataFromServer>( 'boxel.session-rooms', )) ?? {}; let roomId = dmRooms[user]; diff --git a/packages/realm-server/tests/auth-client-test.ts b/packages/realm-server/tests/auth-client-test.ts index 730fe2a6a2..eb4cbd3b27 100644 --- a/packages/realm-server/tests/auth-client-test.ts +++ b/packages/realm-server/tests/auth-client-test.ts @@ -37,6 +37,12 @@ module(basename(__filename), function () { async hashMessageWithSecret(_message: string): Promise { throw new Error('Method not implemented.'); }, + async getAccountDataFromServer() { + return {}; + }, + async setAccountData() { + return Promise.resolve(); + }, } as RealmAuthMatrixClientInterface; let virtualNetwork = new VirtualNetwork(); diff --git a/packages/runtime-common/matrix-backend-authentication.ts b/packages/runtime-common/matrix-backend-authentication.ts index b9359c193e..46500d1d3b 100644 --- a/packages/runtime-common/matrix-backend-authentication.ts +++ b/packages/runtime-common/matrix-backend-authentication.ts @@ -71,7 +71,7 @@ export class MatrixBackendAuthentication { } let dmRooms = - (await this.matrixClient.getAccountData>( + (await this.matrixClient.getAccountDataFromServer>( 'boxel.session-rooms', )) ?? {}; let roomId = dmRooms[user]; @@ -136,7 +136,7 @@ export class MatrixBackendAuthentication { } let dmRooms = - (await this.matrixClient.getAccountData>( + (await this.matrixClient.getAccountDataFromServer>( 'boxel.session-rooms', )) ?? {}; let roomId = dmRooms[user]; diff --git a/packages/runtime-common/matrix-client.ts b/packages/runtime-common/matrix-client.ts index 401029eb8a..db4bd19319 100644 --- a/packages/runtime-common/matrix-client.ts +++ b/packages/runtime-common/matrix-client.ts @@ -212,7 +212,7 @@ export class MatrixClient { } } - async getAccountData(type: string) { + async getAccountDataFromServer(type: string) { if (!this.access) { await this.login(); } @@ -222,7 +222,7 @@ export class MatrixClient { )}/account_data/${type}`, ); if (response.status === 404) { - return; + return null; } let json = await response.json(); if (!response.ok) { diff --git a/packages/runtime-common/realm-auth-client.ts b/packages/runtime-common/realm-auth-client.ts index b3899ebdae..8130d893b6 100644 --- a/packages/runtime-common/realm-auth-client.ts +++ b/packages/runtime-common/realm-auth-client.ts @@ -14,6 +14,8 @@ export interface RealmAuthMatrixClientInterface { joinRoom(room: string): Promise; sendEvent(room: string, type: string, content: any): Promise; hashMessageWithSecret(message: string): Promise; + getAccountDataFromServer(type: string): Promise<{ [k: string]: any } | null>; + setAccountData(type: string, data: any): Promise; } interface Options { @@ -100,6 +102,14 @@ export class RealmAuthClient { if (!rooms.includes(room)) { await this.matrixClient.joinRoom(room); } + let directRooms = + await this.matrixClient.getAccountDataFromServer('m.direct'); + let userId = this.matrixClient.getUserId() as string; + if (!directRooms?.[userId]?.includes(room)) { + await this.matrixClient.setAccountData('m.direct', { + [userId]: [...(directRooms?.[userId] ?? []), room], + }); + } await this.matrixClient.sendEvent(room, 'm.room.message', { body: `auth-response: ${challenge}`, diff --git a/patches/matrix-js-sdk@31.0.0.patch b/patches/matrix-js-sdk@31.0.0.patch index 2ca992c374..c197326609 100644 Binary files a/patches/matrix-js-sdk@31.0.0.patch and b/patches/matrix-js-sdk@31.0.0.patch differ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27e3eb8b01..4a6913b671 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,7 +31,7 @@ patchedDependencies: hash: sbmabuxrk2m44agmppz3qozwvq path: patches/magic-string@0.25.9.patch matrix-js-sdk@31.0.0: - hash: tiapzi7c7tgywvaf6abolorpoi + hash: xllgzidppgfy66ho7r7op3c7wa path: patches/matrix-js-sdk@31.0.0.patch style-loader@2.0.0: hash: xqjji5denmqrswdovljl2t3yv4 @@ -145,7 +145,7 @@ importers: version: 4.17.21 matrix-js-sdk: specifier: ^31.0.0 - version: 31.0.0(patch_hash=tiapzi7c7tgywvaf6abolorpoi) + version: 31.0.0(patch_hash=xllgzidppgfy66ho7r7op3c7wa) openai: specifier: 4.86.1 version: 4.86.1 @@ -243,7 +243,7 @@ importers: version: 6.6.2 matrix-js-sdk: specifier: ^31.0.0 - version: 31.0.0(patch_hash=tiapzi7c7tgywvaf6abolorpoi) + version: 31.0.0(patch_hash=xllgzidppgfy66ho7r7op3c7wa) super-fast-md5: specifier: ^1.0.1 version: 1.0.1 @@ -1618,7 +1618,7 @@ importers: version: 1.8.1 matrix-js-sdk: specifier: ^31.0.0 - version: 31.0.0(patch_hash=tiapzi7c7tgywvaf6abolorpoi) + version: 31.0.0(patch_hash=xllgzidppgfy66ho7r7op3c7wa) moment: specifier: ^2.29.4 version: 2.29.4 @@ -2202,7 +2202,7 @@ importers: dependencies: matrix-js-sdk: specifier: ^31.0.0 - version: 31.0.0(patch_hash=tiapzi7c7tgywvaf6abolorpoi) + version: 31.0.0(patch_hash=xllgzidppgfy66ho7r7op3c7wa) devDependencies: '@babel/preset-typescript': specifier: ^7.24.7 @@ -19730,7 +19730,7 @@ packages: /matrix-events-sdk@0.0.1: resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==} - /matrix-js-sdk@31.0.0(patch_hash=tiapzi7c7tgywvaf6abolorpoi): + /matrix-js-sdk@31.0.0(patch_hash=xllgzidppgfy66ho7r7op3c7wa): resolution: {integrity: sha512-2TqDwEK34NFS0uiOti02CBCupwJcAIxWarOSD0yIrgMpIwSVNB795jEnXxNXz+bgPKsepDmiqeg2DrlinIoW1w==} engines: {node: '>=18.0.0'} dependencies: