feat(editor): Add paginated AI Assistant chat history (#38467)

Co-authored-by: r00gm <[email protected]>
This commit is contained in:
Tuukka Kantola
2026-09-14 14:01:07 +00:00
committed by GitHub
co-authored by r00gm
parent f886fff9fd
commit c979296d6c
24 changed files with 1000 additions and 41 deletions
@@ -31,6 +31,7 @@
| ---- | ---------- |
| IDX_f36dea4d38fe92e0e8f44d5a56 | CREATE INDEX "IDX_f36dea4d38fe92e0e8f44d5a56" ON public.instance_ai_threads USING btree ("resourceId") |
| IDX_instance_ai_threads_projectId | CREATE INDEX "IDX_instance_ai_threads_projectId" ON public.instance_ai_threads USING btree ("projectId") |
| IDX_instance_ai_threads_resourceId_updatedAt_id | CREATE INDEX "IDX_instance_ai_threads_resourceId_updatedAt_id" ON public.instance_ai_threads USING btree ("resourceId", "updatedAt", id) |
| PK_35575100e45cdedeb89ae0643e9 | CREATE UNIQUE INDEX "PK_35575100e45cdedeb89ae0643e9" ON public.instance_ai_threads USING btree (id) |
## Relations
+1
View File
@@ -37,6 +37,7 @@ CREATE TABLE "instance_ai_threads" ("id" varchar PRIMARY KEY NOT NULL, "resource
| ---- | ---------- |
| IDX_instance_ai_threads_projectId | CREATE INDEX "IDX_instance_ai_threads_projectId" ON "instance_ai_threads" ("projectId") |
| IDX_instance_ai_threads_resourceId | CREATE INDEX "IDX_instance_ai_threads_resourceId" ON "instance_ai_threads" ("resourceId") |
| IDX_instance_ai_threads_resourceId_updatedAt_id | CREATE INDEX "IDX_instance_ai_threads_resourceId_updatedAt_id" ON "instance_ai_threads" ("resourceId", "updatedAt", "id") |
| sqlite_autoindex_instance_ai_threads_1 | PRIMARY KEY (id) |
## Relations
+2
View File
@@ -448,6 +448,7 @@ export {
instanceAiResourceAttachmentSchema,
instanceAiWorkflowAttachmentSchema,
InstanceAiThreadMessagesQuery,
InstanceAiThreadHistoryQuery,
INSTANCE_AI_THREAD_MESSAGES_DEFAULT_LIMIT,
INSTANCE_AI_THREAD_MESSAGES_MAX_LIMIT,
INSTANCE_AI_THREAD_MESSAGES_MAX_PAGE,
@@ -545,6 +546,7 @@ export type {
InstanceAiSSEConnectionState,
InstanceAiThreadInfo,
InstanceAiThreadListResponse,
InstanceAiThreadHistoryResponse,
InstanceAiEnsureThreadResponse,
InstanceAiStoredMessage,
InstanceAiThreadMessagesResponse,
@@ -28,6 +28,7 @@ import {
InstanceAiEnsureThreadRequest,
findUnbackedSeedWorkflowTools,
InstanceAiEvalRestoreThreadRequest,
InstanceAiThreadHistoryQuery,
InstanceAiThreadMessagesQuery,
INSTANCE_AI_THREAD_MESSAGES_DEFAULT_LIMIT,
INSTANCE_AI_THREAD_MESSAGES_MAX_LIMIT,
@@ -1123,3 +1124,22 @@ describe('instanceAiHandoffContextSchema', () => {
).toBe(false);
});
});
describe('InstanceAiThreadHistoryQuery', () => {
it('defaults the page size and trims the search text', () => {
expect(InstanceAiThreadHistoryQuery.parse({ search: ' Invoice ' })).toEqual({
limit: 30,
search: 'Invoice',
});
});
it.each([
{ limit: 0 },
{ limit: 101 },
{ search: 'x'.repeat(501) },
{ search: 'invoice\u0000draft' },
{ cursor: '' },
])('rejects %o', (query) => {
expect(InstanceAiThreadHistoryQuery.safeParse(query).success).toBe(false);
});
});
@@ -1694,6 +1694,24 @@ export interface InstanceAiThreadListResponse {
hasMore: boolean;
}
export class InstanceAiThreadHistoryQuery extends Z.class({
limit: z.coerce.number().int().min(1).max(100).default(30),
// Postgres rejects NUL bytes in text parameters, so reject them here as a 400.
search: z
.string()
.trim()
.max(500)
.refine((value) => !value.includes('\u0000'))
.optional(),
cursor: z.string().min(1).max(256).optional(),
}) {}
export interface InstanceAiThreadHistoryResponse {
threads: InstanceAiThreadInfo[];
nextCursor: string | null;
hasMore: boolean;
}
export interface InstanceAiEnsureThreadResponse {
thread: InstanceAiThreadInfo;
created: boolean;
@@ -0,0 +1,11 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddInstanceAiThreadHistoryIndex1789131391410 implements ReversibleMigration {
async up({ schemaBuilder: { createIndex } }: MigrationContext) {
await createIndex('instance_ai_threads', ['resourceId', 'updatedAt', 'id']);
}
async down({ schemaBuilder: { dropIndex } }: MigrationContext) {
await dropIndex('instance_ai_threads', ['resourceId', 'updatedAt', 'id']);
}
}
@@ -7,6 +7,7 @@ const mockDeleteThread = vi.fn();
const mockDeleteThreadsByResourceIdPrefix = vi.fn();
const mockDeleteThreadsByResourceId = vi.fn();
const mockListThreads = vi.fn();
const mockListThreadHistory = vi.fn();
const mockSaveThreadWithProject = vi.fn();
const mockGetThreadProjectId = vi.fn();
const mockSaveMessages = vi.fn();
@@ -18,6 +19,7 @@ const mockAgentMemory = {
deleteThreadsByResourceIdPrefix: mockDeleteThreadsByResourceIdPrefix,
deleteThreadsByResourceId: mockDeleteThreadsByResourceId,
listThreads: mockListThreads,
listThreadHistory: mockListThreadHistory,
saveThreadWithProject: mockSaveThreadWithProject,
getThreadProjectId: mockGetThreadProjectId,
saveMessages: mockSaveMessages,
@@ -123,6 +125,48 @@ function makeThread(id: string, updatedAt: string) {
};
}
describe('InstanceAiMemoryService.listThreadHistory', () => {
beforeEach(() => {
mockListThreadHistory.mockReset();
});
it('encodes the last returned row as the cursor and stops on the final page', async () => {
const rows = ['c', 'b', 'a'].map((id) => makeThread(id, '2026-02-01T00:00:00.000Z'));
mockListThreadHistory.mockResolvedValueOnce(rows).mockResolvedValueOnce([rows[2]]);
const service = createService();
const first = await service.listThreadHistory('user-1', { limit: 2, search: 'invoice' });
expect(first.threads.map((thread) => thread.id)).toEqual(['c', 'b']);
expect(first.hasMore).toBe(true);
expect(first.nextCursor).not.toBeNull();
const second = await service.listThreadHistory('user-1', {
limit: 2,
search: 'invoice',
cursor: first.nextCursor!,
});
expect(mockListThreadHistory).toHaveBeenNthCalledWith(1, 'user-1', 2, 'invoice', undefined);
expect(mockListThreadHistory).toHaveBeenNthCalledWith(2, 'user-1', 2, 'invoice', {
id: 'b',
updatedAt: rows[1].updatedAt,
});
expect(second).toMatchObject({ hasMore: false, nextCursor: null });
expect(second.threads.map((thread) => thread.id)).toEqual(['a']);
});
it('returns an empty final page for a search with no matches', async () => {
mockListThreadHistory.mockResolvedValueOnce([]);
expect(
await createService().listThreadHistory('user-1', { limit: 30, search: 'missing' }),
).toEqual({ threads: [], hasMore: false, nextCursor: null });
});
it('rejects a malformed cursor before querying storage', async () => {
await expect(
createService().listThreadHistory('user-1', { limit: 30, cursor: 'invalid' }),
).rejects.toThrow('Invalid thread history cursor');
expect(mockListThreadHistory).not.toHaveBeenCalled();
});
});
describe('InstanceAiMemoryService.getRichMessages', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -2,6 +2,7 @@ import { WithTimestamps, JsonColumn, Project } from '@n8n/db';
import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
@Entity({ name: 'instance_ai_threads' })
@Index(['resourceId', 'updatedAt', 'id'])
export class InstanceAiThread extends WithTimestamps {
@PrimaryColumn('uuid')
id: string;
@@ -5,6 +5,8 @@ import type {
InstanceAiRichMessagesResponse,
InstanceAiThreadInfo,
InstanceAiThreadListResponse,
InstanceAiThreadHistoryQuery,
InstanceAiThreadHistoryResponse,
InstanceAiThreadMessagesResponse,
InstanceAiThreadOrigin,
InstanceAiThreadSource,
@@ -13,6 +15,7 @@ import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import type { InstanceAiConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import { z } from 'zod';
import {
buildAgentTreeFromEvents,
createSubAgentResourceIdPrefix,
@@ -275,6 +278,48 @@ export class InstanceAiMemoryService {
this.instanceAiConfig = globalConfig.instanceAi;
}
async getThreadInfo(threadId: string): Promise<InstanceAiThreadInfo> {
const thread = await this.agentMemory.getThread(threadId);
if (!thread) throw new NotFoundError('Thread not found');
return this.toThreadInfo(thread);
}
async listThreadHistory(
userId: string,
query: InstanceAiThreadHistoryQuery,
): Promise<InstanceAiThreadHistoryResponse> {
let before: { updatedAt: Date; id: string } | undefined;
if (query.cursor) {
try {
const parsed = z
.object({ updatedAt: z.string().datetime(), id: z.string().min(1).max(256) })
.parse(JSON.parse(Buffer.from(query.cursor, 'base64url').toString('utf8')));
before = { updatedAt: new Date(parsed.updatedAt), id: parsed.id };
} catch {
throw new BadRequestError('Invalid thread history cursor');
}
}
const rows = await this.agentMemory.listThreadHistory(
userId,
query.limit,
query.search,
before,
);
const hasMore = rows.length > query.limit;
const threads = rows.slice(0, query.limit).map((thread) => this.toThreadInfo(thread));
const last = threads.at(-1);
return {
threads,
hasMore,
nextCursor:
hasMore && last
? Buffer.from(JSON.stringify({ updatedAt: last.updatedAt, id: last.id })).toString(
'base64url',
)
: null,
};
}
async listThreads(
userId: string,
page = 0,
@@ -12,6 +12,7 @@ import {
InstanceAiEnsureThreadRequest,
InstanceAiPersistPendingAgentRequest,
InstanceAiThreadMessagesQuery,
InstanceAiThreadHistoryQuery,
InstanceAiAdminSettingsUpdateRequest,
InstanceAiVerifyModelRequest,
InstanceAiVerifySandboxRequest,
@@ -811,6 +812,25 @@ export class InstanceAiController {
return await this.memoryService.listThreads(req.user.id);
}
@Get('/threads/history')
@GlobalScope('instanceAi:message')
async listThreadHistory(
req: AuthenticatedRequest,
_res: Response,
@Query query: InstanceAiThreadHistoryQuery,
) {
this.requireInstanceAiEnabled();
return await this.memoryService.listThreadHistory(req.user.id, query);
}
@Get('/threads/:threadId')
@GlobalScope('instanceAi:message')
async getThread(req: AuthenticatedRequest, _res: Response, @Param('threadId') threadId: string) {
this.requireInstanceAiEnabled();
await this.assertThreadAccess(req.user.id, threadId);
return { thread: await this.memoryService.getThreadInfo(threadId) };
}
@Post('/threads')
@GlobalScope('instanceAi:message')
async ensureThread(
@@ -0,0 +1,108 @@
import { randomUUID } from 'node:crypto';
import { Logger } from '@n8n/backend-common';
import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils';
import { Container } from '@n8n/di';
import { TypeORMAgentMemory } from '../../storage/typeorm-agent-memory';
import { InstanceAiMessageRepository } from '../instance-ai-message.repository';
import { InstanceAiObservationCursorRepository } from '../instance-ai-observation-cursor.repository';
import { InstanceAiObservationLockRepository } from '../instance-ai-observation-lock.repository';
import { InstanceAiObservationRepository } from '../instance-ai-observation.repository';
import { InstanceAiResourceRepository } from '../instance-ai-resource.repository';
import { InstanceAiThreadRepository } from '../instance-ai-thread.repository';
describe('Instance AI thread history', () => {
let repository: InstanceAiThreadRepository;
let memory: TypeORMAgentMemory;
let projectId: string;
const ids = [1, 2, 3, 4].map((id) => '00000000-0000-4000-8000-' + String(id).padStart(12, '0'));
const older = new Date('2026-01-01T00:00:00.000Z');
const newer = new Date('2026-02-01T00:00:00.000Z');
beforeAll(async () => {
await testModules.loadModules(['instance-ai']);
await testDb.init();
repository = Container.get(InstanceAiThreadRepository);
projectId = (await createTeamProject()).id;
memory = new TypeORMAgentMemory(
repository,
Container.get(InstanceAiMessageRepository),
Container.get(InstanceAiResourceRepository),
Container.get(InstanceAiObservationRepository),
Container.get(InstanceAiObservationCursorRepository),
Container.get(InstanceAiObservationLockRepository),
Container.get(Logger),
);
});
beforeEach(async () => {
await repository.delete({});
await repository.insert(
ids.map((id, index) => ({
id,
projectId,
resourceId: 'user-1',
title: index === 1 ? 'Invoice 100%_draft' : 'Invoice',
createdAt: older,
updatedAt: index === 0 ? older : newer,
})),
);
});
afterAll(async () => {
await testDb.terminate();
});
it('uses timestamp and id cursors with a lookahead row and an empty final page', async () => {
const first = await repository.listHistoryPage('user-1', 2);
expect(first.map((thread) => thread.id)).toEqual([ids[3], ids[2], ids[1]]);
const second = await repository.listHistoryPage('user-1', 2, undefined, first[1]);
expect(second.map((thread) => thread.id)).toEqual([ids[1], ids[0]]);
expect(await repository.listHistoryPage('user-1', 2, undefined, second[1])).toEqual([]);
});
it('filters case-insensitively, treats wildcard characters literally, and scopes to the user', async () => {
await repository.insert({
id: randomUUID(),
projectId,
resourceId: 'other-user',
title: 'Invoice',
createdAt: older,
updatedAt: newer,
});
expect(await repository.listHistoryPage('user-1', 10, 'missing')).toEqual([]);
expect(
(await repository.listHistoryPage('user-1', 10, 'iNvOiCe')).map((row) => row.id),
).toEqual([ids[3], ids[2], ids[1], ids[0]]);
expect((await repository.listHistoryPage('user-1', 10, '%_')).map((row) => row.id)).toEqual([
ids[1],
]);
const first = await repository.listHistoryPage('user-1', 2, 'invoice');
expect(
(await repository.listHistoryPage('user-1', 2, 'invoice', first[1])).map((row) => row.id),
).toEqual([ids[1], ids[0]]);
});
it('advances activity after saving messages without shifting the remaining cursor page', async () => {
const first = await repository.listHistoryPage('user-1', 2);
await memory.saveMessages({
threadId: ids[3],
resourceId: 'user-1',
messages: [
{
id: randomUUID(),
role: 'user',
content: [{ type: 'text', text: 'Hello' }],
createdAt: older,
},
],
});
const updated = await repository.findOneByOrFail({ id: ids[3] });
expect(updated.updatedAt.getTime()).toBeGreaterThan(newer.getTime());
expect((await repository.findOneByOrFail({ id: ids[2] })).updatedAt).toEqual(newer);
expect(
(await repository.listHistoryPage('user-1', 2, undefined, first[1])).map((row) => row.id),
).toEqual([ids[1], ids[0]]);
});
});
@@ -1,7 +1,7 @@
import type { Thread } from '@n8n/agents';
import { BaseRepository, TransactionRunner } from '@n8n/db';
import { BaseRepository, TransactionRunner, escapeLike, LIKE_ESCAPE_CLAUSE } from '@n8n/db';
import { Service } from '@n8n/di';
import { DataSource } from '@n8n/typeorm';
import { DataSource, LessThan, Raw } from '@n8n/typeorm';
import { InstanceAiThread } from '../entities/instance-ai-thread.entity';
@@ -45,4 +45,37 @@ export class InstanceAiThreadRepository extends BaseRepository<InstanceAiThread>
return { ...current, ...patch, title: saved.title || undefined, updatedAt: saved.updatedAt };
});
}
/**
* One page of a user's threads, newest activity first, plus one lookahead row so the
* caller can tell whether another page exists. `before` is the last row of the previous
* page: keyset pagination on (updatedAt, id) stays stable while threads get reordered.
*/
async listHistoryPage(
resourceId: string,
limit: number,
search?: string,
before?: { updatedAt: Date; id: string },
) {
const base = {
resourceId,
...(search
? {
title: Raw((alias) => `LOWER(${alias}) LIKE :search ${LIKE_ESCAPE_CLAUSE}`, {
search: `%${escapeLike(search.toLowerCase())}%`,
}),
}
: {}),
};
return await this.find({
where: before
? [
{ ...base, updatedAt: LessThan(before.updatedAt) },
{ ...base, updatedAt: before.updatedAt, id: LessThan(before.id) },
]
: base,
order: { updatedAt: 'DESC', id: 'DESC' },
take: limit + 1,
});
}
}
@@ -258,6 +258,15 @@ export class TypeORMAgentMemory
return thread ? toThread(thread) : null;
}
async listThreadHistory(
resourceId: string,
limit: number,
search?: string,
before?: { updatedAt: Date; id: string },
): Promise<Thread[]> {
return (await this.threadRepo.listHistoryPage(resourceId, limit, search, before)).map(toThread);
}
async listThreads(args: {
filter?: { resourceId?: string };
perPage?: number;
@@ -520,6 +529,7 @@ export class TypeORMAgentMemory
});
await this.messageRepo.save(entities);
await this.threadRepo.update(args.threadId, { updatedAt: new Date() });
}
async deleteMessages(messageIds: string[]): Promise<void> {
@@ -6911,6 +6911,14 @@
"instanceAi.sidebar.group.older": "Older",
"instanceAi.sidebar.renameThread": "Rename",
"instanceAi.sidebar.deleteThread": "Delete",
"instanceAi.threads.searchPlaceholder": "Search conversations",
"instanceAi.threads.actions": "Conversation actions",
"instanceAi.threads.rename": "Rename conversation",
"instanceAi.threads.renameError": "Couldn't rename the chat. Try again.",
"instanceAi.threads.loading": "Loading chats…",
"instanceAi.threads.loadError": "Couldn't load chats. Try again.",
"instanceAi.threads.backToAssistant": "Back to AI Assistant",
"instanceAi.threads.noSearchResults": "No conversations match your search",
"instanceAi.connections.add.computerUse": "Computer Use",
"instanceAi.connections.add.browserUse": "Browser Use",
"instanceAi.connections.disconnectError.title": "Gateway disconnect failed",
@@ -52,6 +52,7 @@ import { useTelemetry } from '@n8n/composables/useTelemetry';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
import { countAttachedNodes } from './utils/buildNodesAttachment';
import { useToast } from '@n8n/composables/useToast';
import { ResponseError } from '@n8n/rest-api-client';
import { provideThread, useInstanceAiStore } from './instanceAi.store';
import {
getAgentBuilderTargetFromThreadMetadata,
@@ -895,7 +896,7 @@ function reconnectThreadAfterHydration(): void {
});
}
// Validate the route's :threadId against the loaded thread list, then connect
// Resolve the route's thread independently of the paginated history, then connect
// this route-scoped runtime. Route changes remount this component, so no
// store-level "active thread" state is needed here.
async function syncRouteToStore() {
@@ -904,16 +905,24 @@ async function syncRouteToStore() {
// submit cannot race past it while the thread list is still loading.
pendingComposerContext.value = getPendingHandoffContext(requestedThreadId);
pendingComposerDraft.value = getPendingComposerDraft(requestedThreadId);
if (!store.threads.length) {
await store.loadThreads();
}
// User may have navigated elsewhere while we awaited
if (requestedThreadId !== props.threadId) return;
if (!store.threads.some((t) => t.id === requestedThreadId)) {
clearPendingThreadHandoff(requestedThreadId);
void router.replace({ name: INSTANCE_AI_VIEW });
return;
try {
await store.loadThread(requestedThreadId);
} catch (error) {
if (router.currentRoute.value.params.threadId !== requestedThreadId) return;
if (
error instanceof ResponseError &&
(error.httpStatusCode === 403 || error.httpStatusCode === 404)
) {
clearPendingThreadHandoff(requestedThreadId);
void router.replace({ name: INSTANCE_AI_VIEW });
} else {
toast.showError(error, i18n.baseText('generic.error'));
}
return;
}
}
if (router.currentRoute.value.params.threadId !== requestedThreadId) return;
if (thread.sseState === 'disconnected') {
reconnectThreadAfterHydration();
}
@@ -0,0 +1,289 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import {
N8nActionDropdown,
N8nButton,
N8nHeading,
N8nIcon,
N8nIconButton,
N8nInput,
N8nText,
} from '@n8n/design-system';
import type { ActionDropdownItem } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@n8n/composables/useToast';
import type { InstanceAiThreadSummary } from '@n8n/api-types';
import PageViewLayout from '@/app/components/layouts/PageViewLayout.vue';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { formatTimeAgo } from '@/app/utils/formatters/dateFormatter';
import { clearPendingThreadHandoff } from './composables/useInstanceAiHandoff';
import { useInstanceAiThreadHistory } from './composables/useInstanceAiThreadHistory';
import { INSTANCE_AI_THREAD_VIEW, INSTANCE_AI_VIEW } from './constants';
import { useInstanceAiStore } from './instanceAi.store';
const store = useInstanceAiStore();
const i18n = useI18n();
const router = useRouter();
const toast = useToast();
useDocumentTitle().set(i18n.baseText('instanceAi.sidebar.chatHistory'));
const { history, search, listRef, sentinelRef, loadMore } = useInstanceAiThreadHistory();
const editingThreadId = ref<string | null>(null);
const editingTitle = ref('');
const threadActions: Array<ActionDropdownItem<'rename' | 'delete'>> = [
{ id: 'rename', label: i18n.baseText('instanceAi.sidebar.renameThread'), icon: 'pencil' },
{ id: 'delete', label: i18n.baseText('instanceAi.sidebar.deleteThread'), icon: 'trash-2' },
];
function startRename(thread: InstanceAiThreadSummary) {
editingThreadId.value = thread.id;
editingTitle.value = thread.title;
}
function focusRenameInput(el: unknown) {
if (el instanceof HTMLInputElement) {
el.focus();
el.select();
}
}
async function confirmRename(thread: InstanceAiThreadSummary) {
// Enter removes the input, which can also fire blur. Rename once.
if (editingThreadId.value !== thread.id) return;
editingThreadId.value = null;
const title = editingTitle.value.trim();
if (!title || title === thread.title) return;
try {
await store.renameThread(thread.id, title);
} catch (error) {
toast.showError(error, i18n.baseText('instanceAi.threads.renameError'));
}
}
async function handleThreadAction(action: string, thread: InstanceAiThreadSummary) {
if (action === 'rename') startRename(thread);
if (action === 'delete' && (await store.deleteThread(thread.id))) {
clearPendingThreadHandoff(thread.id);
}
}
</script>
<template>
<PageViewLayout full-width data-test-id="instance-ai-threads-view">
<div :class="$style.page">
<div :class="$style.header">
<N8nIconButton
icon="arrow-left"
variant="ghost"
:aria-label="i18n.baseText('instanceAi.threads.backToAssistant')"
@click="router.push({ name: INSTANCE_AI_VIEW })"
/>
<N8nHeading :class="$style.heading" tag="h1" size="xlarge" bold>
{{ i18n.baseText('instanceAi.sidebar.chatHistory') }}
</N8nHeading>
<N8nInput
v-model="search"
:class="$style.search"
size="small"
clearable
:maxlength="500"
:placeholder="i18n.baseText('instanceAi.threads.searchPlaceholder')"
data-test-id="instance-ai-threads-search"
>
<template #prefix>
<N8nIcon icon="search" size="small" />
</template>
</N8nInput>
<N8nButton size="small" icon="plus" @click="router.push({ name: INSTANCE_AI_VIEW })">
{{ i18n.baseText('instanceAi.thread.new') }}
</N8nButton>
</div>
<div ref="listRef" :class="$style.list">
<div
v-for="thread in history.threads"
:key="thread.id"
:class="$style.row"
data-test-id="instance-ai-history-thread"
>
<input
v-if="editingThreadId === thread.id"
:ref="focusRenameInput"
v-model="editingTitle"
:class="$style.renameInput"
type="text"
maxlength="255"
@keydown.enter="confirmRename(thread)"
@keydown.escape="editingThreadId = null"
@blur="confirmRename(thread)"
/>
<template v-else>
<RouterLink
:to="{ name: INSTANCE_AI_THREAD_VIEW, params: { threadId: thread.id } }"
:class="$style.link"
:title="thread.title"
@dblclick.prevent="startRename(thread)"
>
<N8nIcon :class="$style.icon" icon="message-circle" size="medium" />
<N8nText :class="$style.title" size="medium">{{ thread.title }}</N8nText>
</RouterLink>
<N8nText size="small" color="text-light">{{ formatTimeAgo(thread.updatedAt) }}</N8nText>
<N8nActionDropdown
:items="threadActions"
:class="$style.actions"
placement="bottom-end"
@select="handleThreadAction($event, thread)"
>
<template #activator>
<N8nIconButton
variant="ghost"
icon="ellipsis-vertical"
:aria-label="i18n.baseText('instanceAi.threads.actions')"
/>
</template>
</N8nActionDropdown>
</template>
</div>
<div v-if="history.loading" :class="$style.status" role="status">
<N8nText color="text-light">{{ i18n.baseText('instanceAi.threads.loading') }}</N8nText>
</div>
<div v-else-if="history.error" :class="$style.status" role="alert">
<N8nText>{{ i18n.baseText('instanceAi.threads.loadError') }}</N8nText>
<N8nButton
variant="outline"
size="small"
data-test-id="instance-ai-threads-retry"
@click="loadMore"
>
{{ i18n.baseText('generic.retry') }}
</N8nButton>
</div>
<div v-else-if="history.hasMore" ref="sentinelRef" :class="$style.sentinel" />
<div v-else-if="history.threads.length === 0" :class="$style.status">
<N8nText color="text-light">
{{
i18n.baseText(
history.search
? 'instanceAi.threads.noSearchResults'
: 'instanceAi.sidebar.noThreads',
)
}}
</N8nText>
</div>
</div>
</div>
</PageViewLayout>
</template>
<style lang="scss" module>
.page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
width: 100%;
max-width: var(--content-container--width);
margin: 0 auto;
}
.header {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
padding-bottom: var(--spacing--sm);
}
.heading {
flex: 1;
min-width: 0;
}
.search {
width: 240px;
}
.list {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.row {
display: flex;
align-items: center;
gap: var(--spacing--xs);
padding: var(--spacing--2xs) var(--spacing--xs);
border-bottom: var(--border);
&:hover,
&:focus-within,
&:has([aria-expanded='true']) {
background-color: var(--color--background--light-1);
.actions {
opacity: 1;
}
}
}
.link {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
flex: 1;
min-width: 0;
color: var(--color--text) !important;
text-decoration: none !important;
}
.icon {
flex-shrink: 0;
color: var(--color--text--tint-1);
}
.title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
flex-shrink: 0;
opacity: 0;
// Touch devices have no hover: keep the menu button visible.
@media (hover: none) {
opacity: 1;
}
}
.renameInput {
flex: 1;
padding: var(--spacing--4xs) var(--spacing--3xs);
font-family: var(--font-family);
font-size: var(--font-size--sm);
line-height: var(--line-height--xl);
color: var(--color--text);
background: var(--color--background--light-2);
border: var(--border);
border-color: var(--color--primary);
border-radius: var(--radius);
outline: none;
}
.status {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing--xs);
padding: var(--spacing--lg);
}
.sentinel {
height: 1px;
}
</style>
@@ -1123,13 +1123,13 @@ describe('InstanceAiThreadView', () => {
});
});
it('prefills pending composer state before the thread list finishes loading', async () => {
it('prefills pending composer state before the thread finishes loading', async () => {
store.threads = [];
let resolveThreadList!: (loaded: boolean) => void;
store.loadThreads.mockImplementation(
let resolveThread!: () => void;
store.loadThread.mockImplementation(
() =>
new Promise((resolve) => {
resolveThreadList = resolve;
resolveThread = resolve;
}),
);
localStorageState.store.set(
@@ -1145,7 +1145,7 @@ describe('InstanceAiThreadView', () => {
const { getByTestId } = renderView({ props: { threadId: 'thread-1' } });
await vi.waitFor(() => {
expect(store.loadThreads).toHaveBeenCalledWith();
expect(store.loadThread).toHaveBeenCalledWith('thread-1');
expect(getByTestId('instance-ai-input-context-chip')).toHaveTextContent('Preview session');
expect(getByTestId('instance-ai-input-draft')).toHaveTextContent('Fix the failed tool');
});
@@ -1158,7 +1158,7 @@ describe('InstanceAiThreadView', () => {
updatedAt: '2026-04-01T00:00:00.000Z',
},
] as typeof store.threads;
resolveThreadList(true);
resolveThread();
await flushPromises();
});
@@ -1649,6 +1649,7 @@ describe('InstanceAiThreadView', () => {
});
it('connects the route thread when navigating to a known thread', async () => {
mockRouteState.params.threadId = 'thread-2';
thread.sseState = 'disconnected';
store.threads = [
...store.threads,
@@ -0,0 +1,109 @@
import { N8nInput } from '@n8n/design-system';
import { shallowMount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { nextTick, reactive } from 'vue';
import InstanceAiThreadsView from '../InstanceAiThreadsView.vue';
type Row = { id: string; title: string; createdAt: string; updatedAt: string };
function emptyHistory(search = '') {
return { search, threads: [] as Row[], hasMore: true, loading: false, error: false };
}
const store = reactive({
threadHistory: emptyHistory(),
loadThreadHistoryPage: vi.fn(),
resetThreadHistory: vi.fn((search = '') => {
store.threadHistory = emptyHistory(search);
}),
renameThread: vi.fn(),
deleteThread: vi.fn(),
});
vi.mock('../instanceAi.store', () => ({ useInstanceAiStore: () => store }));
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock('@n8n/composables/useToast', () => ({ useToast: () => ({ showError: vi.fn() }) }));
vi.mock('@/app/composables/useDocumentTitle', () => ({
useDocumentTitle: () => ({ set: vi.fn() }),
}));
vi.mock('@/app/composables/useIntersectionObserver', () => ({
useIntersectionObserver: () => ({ observe: vi.fn() }),
}));
function mountView() {
return shallowMount(InstanceAiThreadsView, {
global: {
renderStubDefaultSlot: true,
stubs: { RouterLink: { template: '<a><slot /></a>' } },
},
});
}
describe('InstanceAiThreadsView', () => {
beforeEach(() => {
vi.useFakeTimers();
store.resetThreadHistory();
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it('loads the first page on mount and renders the rows', async () => {
const wrapper = mountView();
expect(store.loadThreadHistoryPage).toHaveBeenCalledTimes(1);
store.threadHistory.threads = [
{ id: 'a', title: 'Alpha', createdAt: '2026-01-01', updatedAt: '2026-01-02' },
{ id: 'b', title: 'Beta', createdAt: '2026-01-01', updatedAt: '2026-01-01' },
];
store.threadHistory.hasMore = false;
await nextTick();
const rows = wrapper.findAll('[data-test-id="instance-ai-history-thread"]');
expect(rows.map((row) => row.text())).toEqual([
expect.stringContaining('Alpha'),
expect.stringContaining('Beta'),
]);
});
it('offers a retry after a failed page', async () => {
const wrapper = mountView();
store.threadHistory.error = true;
await nextTick();
expect(wrapper.text()).toContain("Couldn't load chats");
await wrapper.find('[data-test-id="instance-ai-threads-retry"]').trigger('click');
expect(store.loadThreadHistoryPage).toHaveBeenCalledTimes(2);
});
it('restarts the list with the debounced search text', async () => {
const wrapper = mountView();
wrapper.findComponent(N8nInput).vm.$emit('update:modelValue', ' invoice ');
await nextTick();
expect(store.resetThreadHistory).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1000);
expect(store.resetThreadHistory).toHaveBeenCalledWith('invoice');
expect(store.loadThreadHistoryPage).toHaveBeenCalledTimes(2);
});
it('drops a search that is still debouncing when the page is left', async () => {
const wrapper = mountView();
wrapper.findComponent(N8nInput).vm.$emit('update:modelValue', 'invoice');
await nextTick();
wrapper.unmount();
await vi.advanceTimersByTimeAsync(1000);
expect(store.resetThreadHistory).toHaveBeenCalledTimes(1);
expect(store.threadHistory.search).toBe('');
expect(store.loadThreadHistoryPage).toHaveBeenCalledTimes(1);
});
it('clears the history when the page is left', () => {
mountView().unmount();
expect(store.resetThreadHistory).toHaveBeenCalledTimes(1);
expect(store.threadHistory.search).toBe('');
});
});
@@ -1,9 +1,18 @@
import { setActivePinia, createPinia } from 'pinia';
import { beforeAll, afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { ensureThread } from '../instanceAi.api';
import { deleteThread as deleteThreadApi } from '../instanceAi.memory.api';
import {
deleteThread as deleteThreadApi,
fetchThreadHistory,
fetchThread,
renameThread as renameThreadApi,
} from '../instanceAi.memory.api';
import { useInstanceAiStore } from '../instanceAi.store';
import { UNLIMITED_CREDITS, type InstanceAiThreadSummary } from '@n8n/api-types';
import {
UNLIMITED_CREDITS,
type InstanceAiThreadHistoryResponse,
type InstanceAiThreadSummary,
} from '@n8n/api-types';
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: vi.fn().mockReturnValue({
@@ -48,6 +57,8 @@ vi.mock('../instanceAi.api', () => ({
vi.mock('../instanceAi.memory.api', () => ({
fetchThreads: vi.fn().mockResolvedValue({ threads: [], total: 0, page: 1, hasMore: false }),
fetchThread: vi.fn(),
fetchThreadHistory: vi.fn().mockResolvedValue({ threads: [], nextCursor: null, hasMore: false }),
fetchThreadMessages: vi
.fn()
.mockResolvedValue({ threadId: 'thread-1', messages: [], nextEventId: 0 }),
@@ -91,6 +102,73 @@ describe('useInstanceAiStore - runtime registry', () => {
vi.clearAllMocks();
});
function historyThread(id: string) {
return { id, title: id, resourceId: 'user', createdAt: '2026-01-01', updatedAt: '2026-01-01' };
}
it('pages the chat history once per row and drops a response that predates a reset', async () => {
const store = useInstanceAiStore();
vi.mocked(fetchThreadHistory)
.mockResolvedValueOnce({
threads: [historyThread('a'), historyThread('b')],
nextCursor: 'cursor-1',
hasMore: true,
})
.mockResolvedValueOnce({
threads: [historyThread('b'), historyThread('c')],
nextCursor: null,
hasMore: false,
});
await store.loadThreadHistoryPage();
await store.loadThreadHistoryPage();
expect(fetchThreadHistory).toHaveBeenLastCalledWith(expect.anything(), {
limit: 30,
search: undefined,
cursor: 'cursor-1',
});
expect(store.threadHistory.threads.map((thread) => thread.id)).toEqual(['a', 'b', 'c']);
expect(store.threadHistory.hasMore).toBe(false);
const late = Promise.withResolvers<InstanceAiThreadHistoryResponse>();
vi.mocked(fetchThreadHistory).mockReturnValueOnce(late.promise);
store.resetThreadHistory('old');
const pending = store.loadThreadHistoryPage();
store.resetThreadHistory('new');
late.resolve({ threads: [historyThread('late')], nextCursor: null, hasMore: false });
await pending;
expect(store.threadHistory).toMatchObject({ search: 'new', threads: [], loading: false });
});
it('renames and deletes threads on the history page and rolls back a failed rename', async () => {
const store = useInstanceAiStore();
vi.mocked(fetchThreadHistory).mockResolvedValueOnce({
threads: [historyThread('a'), historyThread('b')],
nextCursor: null,
hasMore: false,
});
await store.loadThreadHistoryPage();
await store.renameThread('a', 'Renamed');
expect(renameThreadApi).toHaveBeenCalledWith(expect.anything(), 'a', 'Renamed');
expect(store.threadHistory.threads[0].title).toBe('Renamed');
vi.mocked(renameThreadApi).mockRejectedValueOnce(new Error('offline'));
await expect(store.renameThread('a', 'Rejected')).rejects.toThrow('offline');
expect(store.threadHistory.threads[0].title).toBe('Renamed');
await store.deleteThread('b');
expect(mockDeleteThread).toHaveBeenCalledWith(expect.anything(), 'b');
expect(store.threadHistory.threads.map((thread) => thread.id)).toEqual(['a']);
});
it('loadThread adds a thread the sidebar list does not hold, once', async () => {
const store = useInstanceAiStore();
vi.mocked(fetchThread).mockResolvedValue({ thread: historyThread('old') });
await store.loadThread('old');
await store.loadThread('old');
expect(store.threads.map((thread) => thread.id)).toEqual(['old']);
});
it('returns the same runtime for the same thread id', () => {
const store = useInstanceAiStore();
@@ -0,0 +1,47 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { refDebounced } from '@vueuse/core';
import { getDebounceTime } from '@n8n/composables/useDebounce';
import { useIntersectionObserver } from '@/app/composables/useIntersectionObserver';
import { DEBOUNCE_TIME } from '@/app/constants';
import { useInstanceAiStore } from '../instanceAi.store';
/**
* Server-side searched, cursor-paged chat list for one mounted list at a time.
* Bind `search` to the input, `listRef` to the scroll container and `sentinelRef` to an
* element after the last row. Loads on mount and clears the store state on unmount.
*/
export function useInstanceAiThreadHistory() {
const store = useInstanceAiStore();
const history = computed(() => store.threadHistory);
const search = ref('');
const listRef = ref<HTMLElement | null>(null);
const sentinelRef = ref<HTMLElement | null>(null);
function loadMore() {
void store.loadThreadHistoryPage();
}
// A component-scoped watcher stops on unmount, so a search that is still debouncing
// when the user leaves does not reload the shared state.
const debouncedSearch = refDebounced(search, getDebounceTime(DEBOUNCE_TIME.INPUT.SEARCH));
watch(debouncedSearch, (value) => {
store.resetThreadHistory(value.trim());
loadMore();
});
// The observer fires once per observe() call. Re-arm it after every page so a sentinel that
// is still in view (short list in a tall window) loads the next page right away.
const { observe } = useIntersectionObserver({ root: listRef, onIntersect: loadMore });
watch(
[sentinelRef, () => history.value.loading],
([sentinel, loading]) => {
if (sentinel && !loading) observe(sentinel);
},
{ flush: 'post' },
);
onMounted(loadMore);
onBeforeUnmount(() => store.resetThreadHistory());
return { history, search, listRef, sentinelRef, loadMore };
}
@@ -2,6 +2,7 @@ import { INSTANCE_AI_THREAD_SOURCES, type InstanceAiThreadSource } from '@n8n/ap
export const INSTANCE_AI_VIEW = 'InstanceAi';
export const INSTANCE_AI_THREAD_VIEW = 'InstanceAiThread';
export const INSTANCE_AI_THREADS_VIEW = 'InstanceAiThreads';
export const INSTANCE_AI_SETTINGS_VIEW = 'InstanceAiSettings';
export const INSTANCE_AI_PROJECT_ID_QUERY = 'projectId';
/**
@@ -57,6 +58,7 @@ export function isInstanceAiThreadSource(value: unknown): value is InstanceAiThr
const INSTANCE_AI_CHAT_ROUTE_NAMES: ReadonlySet<string> = new Set([
INSTANCE_AI_VIEW,
INSTANCE_AI_THREAD_VIEW,
INSTANCE_AI_THREADS_VIEW,
INSTANCE_AI_NEW_VIEW,
]);
@@ -3,6 +3,8 @@ import type { IRestApiContext } from '@n8n/rest-api-client';
import type {
InstanceAiThreadInfo,
InstanceAiThreadListResponse,
InstanceAiThreadHistoryQuery,
InstanceAiThreadHistoryResponse,
InstanceAiRichMessagesResponse,
InstanceAiThreadStatusResponse,
InstanceAiRunDebugResponse,
@@ -16,6 +18,20 @@ export async function fetchThreads(
return await makeRestApiRequest(context, 'GET', '/instance-ai/threads');
}
export async function fetchThreadHistory(
context: IRestApiContext,
query: InstanceAiThreadHistoryQuery,
): Promise<InstanceAiThreadHistoryResponse> {
return await makeRestApiRequest(context, 'GET', '/instance-ai/threads/history', query);
}
export async function fetchThread(
context: IRestApiContext,
threadId: string,
): Promise<{ thread: InstanceAiThreadInfo }> {
return await makeRestApiRequest(context, 'GET', `/instance-ai/threads/${threadId}`);
}
export async function deleteThread(context: IRestApiContext, threadId: string): Promise<void> {
await makeRestApiRequest(context, 'DELETE', `/instance-ai/threads/${threadId}`);
}
@@ -5,6 +5,8 @@ import { useToast } from '@n8n/composables/useToast';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import {
UNLIMITED_CREDITS,
type InstanceAiThreadHistoryResponse,
type InstanceAiThreadInfo,
type InstanceAiThreadSummary,
type InstanceAiAttachment,
type InstanceAiNodesAttachment,
@@ -18,6 +20,8 @@ import {
import { useInstanceAiSettingsStore } from './instanceAiSettings.store';
import {
fetchThreads as fetchThreadsApi,
fetchThreadHistory,
fetchThread,
deleteThread as deleteThreadApi,
renameThread as renameThreadApi,
updateThreadMetadata as updateThreadMetadataApi,
@@ -30,6 +34,16 @@ export type { PendingConfirmationItem, ThreadRuntime } from './instanceAi.thread
type InstanceAiCreditsPushData = Extract<PushMessage, { type: 'updateInstanceAiCredits' }>['data'];
const THREAD_HISTORY_PAGE_SIZE = 30;
const emptyThreadHistory = (search = '') => ({
search,
threads: [] as InstanceAiThreadSummary[],
hasMore: true,
loading: false,
error: false,
});
export const useInstanceAiStore = defineStore('instanceAi', () => {
const rootStore = useRootStore();
const instanceAiSettingsStore = useInstanceAiSettingsStore();
@@ -39,6 +53,8 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
// --- Instance-level state ---
const threads = ref<InstanceAiThreadSummary[]>([]);
// The chat history page: cursor-paginated and searchable, kept apart from the sidebar list.
const threadHistory = ref(emptyThreadHistory());
const debugMode = ref(false);
// Credits are instance-level state (not per-thread). Re-fetched on mount via fetchCredits(),
// and updated in real-time via the 'updateInstanceAiCredits' push event.
@@ -52,8 +68,7 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
const runtimes = shallowReactive(new Map<string, ThreadRuntime>());
const runtimeHooks = {
onTitleUpdated: (threadId, title) => {
const thread = threads.value.find((t) => t.id === threadId);
if (thread) thread.title = title;
for (const thread of localThreadEntries(threadId)) thread.title = title;
},
// Refresh thread list to pick up auto-generated titles
onRunFinish: () => {
@@ -163,6 +178,21 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
// --- Thread list & lifecycle ---
function toThreadSummary(thread: InstanceAiThreadInfo): InstanceAiThreadSummary {
return {
id: thread.id,
title: thread.title || NEW_CONVERSATION_TITLE,
createdAt: thread.createdAt,
updatedAt: thread.updatedAt,
metadata: thread.metadata ?? undefined,
};
}
/** Every local copy of a thread; the sidebar list and the history page can both hold one. */
function localThreadEntries(threadId: string): InstanceAiThreadSummary[] {
return [...threads.value, ...threadHistory.value.threads].filter((t) => t.id === threadId);
}
async function loadThreads(): Promise<boolean> {
try {
const result = await fetchThreadsApi(rootStore.restApiContext);
@@ -173,14 +203,7 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
// (e.g. a freshly created thread that hasn't been persisted yet)
const serverIds = new Set(result.threads.map((t) => t.id));
const localOnly = threads.value.filter((t) => !serverIds.has(t.id));
const serverThreads: InstanceAiThreadSummary[] = result.threads.map((t) => ({
id: t.id,
title: t.title || NEW_CONVERSATION_TITLE,
createdAt: t.createdAt,
updatedAt: t.updatedAt,
metadata: t.metadata ?? undefined,
}));
threads.value = [...localOnly, ...serverThreads];
threads.value = [...localOnly, ...result.threads.map(toThreadSummary)];
return true;
} catch {
// Silently ignore — threads will remain client-side only
@@ -188,6 +211,57 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
}
}
/** Fetch a thread the sidebar list does not hold, e.g. an older one opened by URL. */
async function loadThread(threadId: string): Promise<void> {
const { thread } = await fetchThread(rootStore.restApiContext, threadId);
persistedThreadIds.add(thread.id);
if (!threads.value.some((t) => t.id === thread.id)) {
threads.value.push(toThreadSummary(thread));
}
}
let threadHistoryCursor: string | undefined;
// Bumped by every reset so a response still in flight for the old state is dropped.
let threadHistoryRequest = 0;
function resetThreadHistory(search = ''): void {
threadHistoryRequest++;
threadHistoryCursor = undefined;
threadHistory.value = emptyThreadHistory(search);
}
async function loadThreadHistoryPage(): Promise<void> {
const history = threadHistory.value;
if (history.loading || !history.hasMore) return;
const request = threadHistoryRequest;
history.loading = true;
history.error = false;
let result: InstanceAiThreadHistoryResponse | undefined;
try {
result = await fetchThreadHistory(rootStore.restApiContext, {
limit: THREAD_HISTORY_PAGE_SIZE,
search: history.search || undefined,
cursor: threadHistoryCursor,
});
} catch {
// Reported through `error` below
}
if (request !== threadHistoryRequest) return;
history.loading = false;
if (!result) {
history.error = true;
return;
}
for (const thread of result.threads) {
persistedThreadIds.add(thread.id);
}
// A thread that gets activity while paging moves onto a later page; keep each row once.
const known = new Set(history.threads.map((t) => t.id));
history.threads.push(...result.threads.filter((t) => !known.has(t.id)).map(toThreadSummary));
threadHistoryCursor = result.nextCursor ?? undefined;
history.hasMore = result.hasMore;
}
async function syncThread(
threadId: string,
projectId: string,
@@ -218,13 +292,7 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
return;
}
threads.value.unshift({
id: result.thread.id,
title: result.thread.title || NEW_CONVERSATION_TITLE,
createdAt: result.thread.createdAt,
updatedAt: result.thread.updatedAt,
metadata: result.thread.metadata ?? undefined,
});
threads.value.unshift(toThreadSummary(result.thread));
}
/**
@@ -254,20 +322,27 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
// Remove thread from list
threads.value = threads.value.filter((t) => t.id !== threadId);
threadHistory.value.threads = threadHistory.value.threads.filter((t) => t.id !== threadId);
disposeRuntime(threadId);
return true;
}
async function renameThread(threadId: string, title: string): Promise<void> {
const thread = threads.value.find((t) => t.id === threadId);
if (thread) {
thread.title = title;
}
const entries = localThreadEntries(threadId);
const previousTitle = entries[0]?.title;
for (const entry of entries) entry.title = title;
// Only call API for threads that have been persisted to the backend
if (persistedThreadIds.has(threadId)) {
if (!persistedThreadIds.has(threadId)) return;
try {
await renameThreadApi(rootStore.restApiContext, threadId, title);
} catch (error) {
// Roll back the optimistic title so the list does not keep a name the server rejected
if (previousTitle !== undefined) {
for (const entry of entries) entry.title = previousTitle;
}
throw error;
}
}
@@ -363,6 +438,10 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
updateThreadMetadata,
setThreadMetadata,
loadThreads,
loadThread,
threadHistory,
resetThreadHistory,
loadThreadHistoryPage,
fetchCredits,
handleCreditsPush,
getOrCreateRuntime,
@@ -7,6 +7,7 @@ import {
INSTANCE_AI_THREAD_VIEW,
INSTANCE_AI_SETTINGS_VIEW,
INSTANCE_AI_NEW_VIEW,
INSTANCE_AI_THREADS_VIEW,
} from './constants';
import {
ensurePersonalProjectId,
@@ -47,6 +48,7 @@ function hasInstanceAiSettingsContent(): boolean {
const InstanceAiView = async () => await import('./InstanceAiView.vue');
const InstanceAiEmptyView = async () => await import('./InstanceAiEmptyView.vue');
const InstanceAiThreadView = async () => await import('./InstanceAiThreadView.vue');
const InstanceAiThreadsView = async () => await import('./InstanceAiThreadsView.vue');
const SettingsInstanceAiView = async () => await import('./views/SettingsInstanceAiView.vue');
export const InstanceAiModule = defineFrontendModule({
@@ -119,6 +121,11 @@ export const InstanceAiModule = defineFrontendModule({
path: '',
component: InstanceAiEmptyView,
},
{
name: INSTANCE_AI_THREADS_VIEW,
path: 'history',
component: InstanceAiThreadsView,
},
{
name: INSTANCE_AI_THREAD_VIEW,
path: ':threadId',