mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 21:49:36 +02:00
feat(editor): Show what each Assistant chat needs in the sidebar (no-changelog)
Move the sidebar Chats section into AssistantChatsSection. Each row wraps its menu item in a div. With experience modes on, each recent chat shows a state: waiting for you, working, failed or ready to review. The editor keeps the time that the user last viewed each chat in this browser. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gK4jZbBjL8toMAnrfJ4mh
This commit is contained in:
parent
a56279f513
commit
3392cff11e
|
|
@ -7340,6 +7340,11 @@
|
|||
"instanceAi.threads.chats": "Chats",
|
||||
"instanceAi.threads.backToAssistant": "Back to AI Assistant",
|
||||
"instanceAi.threads.noSearchResults": "No conversations match your search",
|
||||
"instanceAi.threadState.needsYou": "Waiting for you",
|
||||
"instanceAi.threadState.working": "Working",
|
||||
"instanceAi.threadState.failed": "Failed",
|
||||
"instanceAi.threadState.ready": "Ready to review",
|
||||
"instanceAi.threadState.rowLabel": "{title}, {state}",
|
||||
"instanceAi.connections.add.computerUse": "Computer Use",
|
||||
"instanceAi.connections.add.browserUse": "Browser Use",
|
||||
"instanceAi.connections.disconnectError.title": "Gateway disconnect failed",
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ import { useAgentEvalsFlag } from '@/features/ai/evaluation.ee/composables/useAg
|
|||
import { useAgentCapabilitySummary } from '@/features/agents/composables/useAgentCapabilitySummary';
|
||||
import { useAgentEvalsStore } from '@/features/agents/agentEvals.store';
|
||||
import { useIsAgentWorking } from './composables/useIsAgentWorking';
|
||||
import { useMarkThreadViewed } from './navigation/useThreadLastViewed';
|
||||
import { useAutomationOffer } from './composables/useAutomationOffer';
|
||||
import { useAgentReturnContextStore } from '@/features/agents/agentReturnContext.store';
|
||||
import { useRecentWorkflowsStore } from '@/app/stores/recentWorkflows.store';
|
||||
|
|
@ -119,6 +120,7 @@ const agentEvalsStore = useAgentEvalsStore();
|
|||
// Passed the local runtime because this component provides the thread rather
|
||||
// than inheriting it, so the composable's own `useThread()` inject would fail.
|
||||
const isAgentWorking = useIsAgentWorking(thread);
|
||||
useMarkThreadViewed(() => props.threadId, isAgentWorking);
|
||||
|
||||
// The agent the builder actually persisted in this thread. Absent until then,
|
||||
// which is what keeps the suggestion from firing mid-build.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
deleteThread as deleteThreadApi,
|
||||
fetchThreadHistory,
|
||||
fetchThread,
|
||||
fetchThreads,
|
||||
renameThread as renameThreadApi,
|
||||
} from '../instanceAi.memory.api';
|
||||
import { useInstanceAiStore } from '../instanceAi.store';
|
||||
|
|
@ -187,6 +188,73 @@ describe('useInstanceAiStore - runtime registry', () => {
|
|||
expect(store.threads[0]).toMatchObject({ title: 'Refined', metadata: { key: 'value' } });
|
||||
});
|
||||
|
||||
it('keeps the state, needs-input flag and activity time of each listed thread', async () => {
|
||||
const store = useInstanceAiStore();
|
||||
vi.mocked(fetchThreads).mockResolvedValueOnce({
|
||||
threads: [
|
||||
{
|
||||
...historyThread('waiting'),
|
||||
state: 'needs-you',
|
||||
needsInput: true,
|
||||
lastActivityAt: '2026-02-01T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
...historyThread('finished'),
|
||||
state: 'idle',
|
||||
needsInput: false,
|
||||
lastActivityAt: '2026-02-01T09:00:00.000Z',
|
||||
},
|
||||
// The server sends the fields only for the first 50 threads.
|
||||
historyThread('older'),
|
||||
],
|
||||
total: 3,
|
||||
page: 1,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
await store.loadThreads();
|
||||
|
||||
expect(
|
||||
store.threads.map(({ id, state, needsInput, lastActivityAt }) => ({
|
||||
id,
|
||||
state,
|
||||
needsInput,
|
||||
lastActivityAt,
|
||||
})),
|
||||
).toStrictEqual([
|
||||
{
|
||||
id: 'waiting',
|
||||
state: 'needs-you',
|
||||
needsInput: true,
|
||||
lastActivityAt: '2026-02-01T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'finished',
|
||||
state: 'idle',
|
||||
needsInput: false,
|
||||
lastActivityAt: '2026-02-01T09:00:00.000Z',
|
||||
},
|
||||
{ id: 'older', state: undefined, needsInput: undefined, lastActivityAt: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the overview fields of a thread fetched for the history page', async () => {
|
||||
const store = useInstanceAiStore();
|
||||
vi.mocked(fetchThreadHistory).mockResolvedValueOnce({
|
||||
threads: [{ ...historyThread('a'), state: 'failed', lastActivityAt: '2026-02-01T08:00:00Z' }],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
await store.loadThreadHistoryPage();
|
||||
|
||||
expect(store.threadHistory.threads[0]).toMatchObject({
|
||||
id: 'a',
|
||||
state: 'failed',
|
||||
lastActivityAt: '2026-02-01T08:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the same runtime for the same thread id', () => {
|
||||
const store = useInstanceAiStore();
|
||||
|
||||
|
|
|
|||
|
|
@ -203,6 +203,9 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
|
|||
createdAt: thread.createdAt,
|
||||
updatedAt: thread.updatedAt,
|
||||
metadata: thread.metadata ?? undefined,
|
||||
state: thread.state,
|
||||
needsInput: thread.needsInput,
|
||||
lastActivityAt: thread.lastActivityAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,296 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute, type RouteLocationRaw } from 'vue-router';
|
||||
import type { InstanceAiThreadSummary } from '@n8n/api-types';
|
||||
import { N8nIcon, N8nMenuItem, N8nText, N8nTooltip } from '@n8n/design-system';
|
||||
import type { IMenuItem } from '@n8n/design-system';
|
||||
import { type BaseTextKey, useI18n } from '@n8n/i18n';
|
||||
import { useDocumentVisibility } from '@/app/composables/useDocumentVisibility';
|
||||
import { INSTANCE_AI_THREADS_VIEW, INSTANCE_AI_THREAD_VIEW } from '../constants';
|
||||
import { useInstanceAiAvailable } from '../composables/useInstanceAiAvailability';
|
||||
import { useInstanceAiStore } from '../instanceAi.store';
|
||||
import { useExperienceMode } from '../experience/useExperienceMode';
|
||||
import { threadDisplayState, type ThreadDisplayState } from './threadDisplayState';
|
||||
import { useThreadLastViewed } from './useThreadLastViewed';
|
||||
|
||||
const CHATS_COLLAPSED_KEY = 'n8n:sidebar:instance-ai-chats-collapsed';
|
||||
|
||||
type ShownState = Exclude<ThreadDisplayState, 'done'>;
|
||||
|
||||
const STATE_LABEL_KEYS = {
|
||||
'needs-you': 'instanceAi.threadState.needsYou',
|
||||
working: 'instanceAi.threadState.working',
|
||||
failed: 'instanceAi.threadState.failed',
|
||||
ready: 'instanceAi.threadState.ready',
|
||||
} as const satisfies Record<ShownState, BaseTextKey>;
|
||||
|
||||
type ChatRow = {
|
||||
thread: InstanceAiThreadSummary;
|
||||
to: RouteLocationRaw;
|
||||
item: IMenuItem;
|
||||
state?: { kind: ShownState; label: string; rowLabel: string };
|
||||
};
|
||||
|
||||
const props = defineProps<{ collapsed: boolean }>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const route = useRoute();
|
||||
const instanceAiStore = useInstanceAiStore();
|
||||
const isInstanceAiNavVisible = useInstanceAiAvailable();
|
||||
const { isEnabled: showStates } = useExperienceMode();
|
||||
const { lastViewedAt } = useThreadLastViewed();
|
||||
|
||||
const isChatsCollapsed = ref(localStorage.getItem(CHATS_COLLAPSED_KEY) === 'true');
|
||||
watch(isChatsCollapsed, (val) => localStorage.setItem(CHATS_COLLAPSED_KEY, String(val)));
|
||||
|
||||
// The recent chats read the store list; fetch it once the AI Assistant entry is shown.
|
||||
watch(
|
||||
isInstanceAiNavVisible,
|
||||
(visible) => {
|
||||
if (visible) void instanceAiStore.loadThreads();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Another tab can start a chat; refresh the list when the user comes back to this one.
|
||||
const { onDocumentVisible } = useDocumentVisibility();
|
||||
onDocumentVisible(() => {
|
||||
if (isInstanceAiNavVisible.value) void instanceAiStore.loadThreads();
|
||||
});
|
||||
|
||||
const recentThreads = computed(() => {
|
||||
const recent = instanceAiStore.threads.slice(0, 5);
|
||||
// Keep the open chat in the list when it is older than the five most recent
|
||||
// ones, e.g. opened from the history page or by URL.
|
||||
const openThreadId = route.name === INSTANCE_AI_THREAD_VIEW ? route.params.threadId : undefined;
|
||||
if (typeof openThreadId !== 'string' || recent.some((t) => t.id === openThreadId)) return recent;
|
||||
const openThread = instanceAiStore.threads.find((t) => t.id === openThreadId);
|
||||
return openThread ? [...recent.slice(0, 4), openThread] : recent;
|
||||
});
|
||||
|
||||
function toRowState(thread: InstanceAiThreadSummary): ChatRow['state'] {
|
||||
if (!showStates.value) return undefined;
|
||||
const kind = threadDisplayState(thread, lastViewedAt(thread.id));
|
||||
if (kind === undefined || kind === 'done') return undefined;
|
||||
const label = i18n.baseText(STATE_LABEL_KEYS[kind]);
|
||||
const rowLabel = i18n.baseText('instanceAi.threadState.rowLabel', {
|
||||
interpolate: { title: thread.title, state: label },
|
||||
});
|
||||
return { kind, label, rowLabel };
|
||||
}
|
||||
|
||||
function toRow(thread: InstanceAiThreadSummary): ChatRow {
|
||||
const to = { name: INSTANCE_AI_THREAD_VIEW, params: { threadId: thread.id } };
|
||||
const item: IMenuItem = {
|
||||
id: `instance-ai-thread-${thread.id}`,
|
||||
icon: 'message-circle',
|
||||
label: thread.title,
|
||||
route: { to },
|
||||
};
|
||||
return { thread, to, item, state: toRowState(thread) };
|
||||
}
|
||||
|
||||
const rows = computed(() => recentThreads.value.map(toRow));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isInstanceAiNavVisible && !props.collapsed && rows.length > 0"
|
||||
:class="$style.instanceAiSidebar"
|
||||
data-test-id="instance-ai-sidebar-chats"
|
||||
>
|
||||
<div :class="$style.chatsHeader">
|
||||
<button
|
||||
type="button"
|
||||
:class="$style.chatsToggle"
|
||||
:aria-expanded="!isChatsCollapsed"
|
||||
@click="isChatsCollapsed = !isChatsCollapsed"
|
||||
>
|
||||
<N8nText size="small" bold color="text-light">
|
||||
{{ i18n.baseText('instanceAi.threads.chats') }}
|
||||
</N8nText>
|
||||
<N8nIcon
|
||||
icon="chevron-down"
|
||||
size="small"
|
||||
:class="[$style.chevron, isChatsCollapsed ? $style.chevronCollapsed : '']"
|
||||
/>
|
||||
</button>
|
||||
<RouterLink :to="{ name: INSTANCE_AI_THREADS_VIEW }" :class="$style.chatsViewAll">
|
||||
{{ i18n.baseText('instanceAi.threads.viewAll') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div v-if="!isChatsCollapsed">
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="row.thread.id"
|
||||
:class="[$style.chatRow, { [$style.withState]: row.state }]"
|
||||
>
|
||||
<N8nMenuItem :item="row.item" :aria-label="row.state?.rowLabel" scroll-label-on-overflow />
|
||||
<!-- The row label already names the state, so screen readers skip this copy of the link. -->
|
||||
<span v-if="row.state" :class="$style.stateSlot">
|
||||
<N8nTooltip placement="right" :content="row.state.label">
|
||||
<RouterLink
|
||||
:to="row.to"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
:class="$style.stateLink"
|
||||
:data-test-id="`instance-ai-thread-state-${row.thread.id}`"
|
||||
>
|
||||
<N8nIcon
|
||||
v-if="row.state.kind === 'needs-you'"
|
||||
icon="circle-alert"
|
||||
color="--icon-color--warning"
|
||||
size="small"
|
||||
/>
|
||||
<N8nIcon
|
||||
v-else-if="row.state.kind === 'working'"
|
||||
icon="loader-circle"
|
||||
spin
|
||||
size="small"
|
||||
/>
|
||||
<N8nIcon
|
||||
v-else-if="row.state.kind === 'failed'"
|
||||
icon="circle-x"
|
||||
color="--icon-color--danger"
|
||||
size="small"
|
||||
/>
|
||||
<span v-else :class="$style.readyDot" />
|
||||
</RouterLink>
|
||||
</N8nTooltip>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@use '@n8n/design-system/css/mixins/_focus.scss' as focus;
|
||||
|
||||
.instanceAiSidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--4xs);
|
||||
padding: var(--spacing--3xs) var(--spacing--3xs) var(--spacing--xs);
|
||||
}
|
||||
|
||||
.chatsHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-top: var(--spacing--4xs);
|
||||
border-radius: var(--spacing--4xs);
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color--background--light-1);
|
||||
color: var(--color--text--shade-1);
|
||||
|
||||
.chevron {
|
||||
color: var(--color--text--shade-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chatsToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--4xs);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: var(--spacing--4xs) var(--spacing--3xs);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--spacing--4xs);
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
|
||||
&:focus-visible {
|
||||
@include focus.focus-ring;
|
||||
}
|
||||
}
|
||||
|
||||
.chatsViewAll {
|
||||
flex-shrink: 0;
|
||||
padding: var(--spacing--4xs) var(--spacing--3xs);
|
||||
color: var(--text-color--subtler);
|
||||
font-size: var(--font-size--2xs);
|
||||
font-weight: var(--font-weight--regular);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: var(--text-color--subtle);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@include focus.focus-ring;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.chatsViewAll {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chatsHeader:hover .chatsViewAll,
|
||||
.chatsHeader:has(.chatsToggle:focus-visible) .chatsViewAll,
|
||||
.chatsViewAll:focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--color--text--tint-1);
|
||||
transition: transform 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chevronCollapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.chatRow {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// The state icon sits over the end of the row, so the label stops before it and the row
|
||||
// keeps its hover colour while the pointer is on the icon.
|
||||
.withState {
|
||||
a[role='menuitem'] {
|
||||
padding-right: var(--spacing--lg);
|
||||
}
|
||||
|
||||
&:hover a[role='menuitem'] {
|
||||
background-color: var(--color--background--light-1);
|
||||
color: var(--color--text--shade-1);
|
||||
}
|
||||
}
|
||||
|
||||
.stateSlot {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: var(--spacing--2xs);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
// The height of an N8nMenuItem row, so that the icon is centred on the row.
|
||||
height: var(--spacing--xl);
|
||||
}
|
||||
|
||||
.stateLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-color--subtle);
|
||||
}
|
||||
|
||||
.readyDot {
|
||||
display: block;
|
||||
width: var(--spacing--2xs);
|
||||
height: var(--spacing--2xs);
|
||||
border-radius: var(--radius--full);
|
||||
background-color: var(--icon-color--info);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,364 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createRouter, createMemoryHistory, type Router } from 'vue-router';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { waitFor, within } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { InstanceAiThreadSummary } from '@n8n/api-types';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import { useUsersStore } from '@n8n/stores/users.store';
|
||||
import { useRBACStore } from '@n8n/stores/rbac.store';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { getTooltip, hoverTooltipTrigger, mockedStore } from '@/__tests__/utils';
|
||||
import { useInstanceAiStore } from '../../instanceAi.store';
|
||||
import {
|
||||
INSTANCE_AI_THREADS_VIEW,
|
||||
INSTANCE_AI_THREAD_VIEW,
|
||||
INSTANCE_AI_VIEW,
|
||||
} from '../../constants';
|
||||
import { resetThreadLastViewedState, useThreadLastViewed } from '../useThreadLastViewed';
|
||||
import AssistantChatsSection from '../AssistantChatsSection.vue';
|
||||
|
||||
const LAST_VIEWED_KEY = 'n8n:instance-ai:last-viewed:user-1';
|
||||
const COLLAPSED_KEY = 'n8n:sidebar:instance-ai-chats-collapsed';
|
||||
const T0 = '2026-03-01T10:00:00.000Z';
|
||||
const T1 = '2026-03-01T11:00:00.000Z';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
const renderComponent = createComponentRenderer(AssistantChatsSection);
|
||||
|
||||
let router: Router;
|
||||
let instanceAiStore: ReturnType<typeof mockedStore<typeof useInstanceAiStore>>;
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: { template: '<div />' } },
|
||||
{ path: '/assistant', name: INSTANCE_AI_VIEW, component: { template: '<div />' } },
|
||||
{
|
||||
path: '/assistant/history',
|
||||
name: INSTANCE_AI_THREADS_VIEW,
|
||||
component: { template: '<div />' },
|
||||
},
|
||||
{
|
||||
path: '/assistant/:threadId',
|
||||
name: INSTANCE_AI_THREAD_VIEW,
|
||||
component: { template: '<div />' },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function configureInstanceAi({
|
||||
available = true,
|
||||
experienceModes = false,
|
||||
}: { available?: boolean; experienceModes?: boolean } = {}) {
|
||||
const settingsStore = mockedStore(useSettingsStore);
|
||||
settingsStore.isModuleActive = vi.fn().mockReturnValue(available);
|
||||
settingsStore.moduleSettings = {
|
||||
'instance-ai': {
|
||||
enabled: true,
|
||||
mcpConnectionsAvailable: true,
|
||||
localGatewayDisabled: false,
|
||||
browserUseEnabled: true,
|
||||
proxyEnabled: false,
|
||||
cloudManaged: false,
|
||||
setupCompleted: true,
|
||||
sandboxEnabled: true,
|
||||
workflowBuilderAvailable: true,
|
||||
sandboxUnavailableReason: null,
|
||||
runDebugEnabled: false,
|
||||
experience: { enabled: experienceModes, defaultMode: 'simple' },
|
||||
},
|
||||
};
|
||||
vi.mocked(useRBACStore().hasScope).mockImplementation((scope) => scope === 'instanceAi:message');
|
||||
}
|
||||
|
||||
function chat(id: string, title: string, overview: Partial<InstanceAiThreadSummary> = {}) {
|
||||
return { id, title, createdAt: T0, updatedAt: T0, ...overview } satisfies InstanceAiThreadSummary;
|
||||
}
|
||||
|
||||
/** One chat for each state the sidebar can show, plus one the viewer already saw. */
|
||||
function chatsInEveryState(): InstanceAiThreadSummary[] {
|
||||
return [
|
||||
chat('waiting', 'Approve invoice', {
|
||||
state: 'needs-you',
|
||||
needsInput: true,
|
||||
lastActivityAt: T1,
|
||||
}),
|
||||
chat('busy', 'Build CRM sync', { state: 'working', lastActivityAt: T1 }),
|
||||
chat('broken', 'Fix Slack alert', { state: 'failed', lastActivityAt: T1 }),
|
||||
chat('unseen', 'Weekly report', { state: 'idle', lastActivityAt: T1 }),
|
||||
chat('seen', 'Team digest', { state: 'idle', lastActivityAt: T0 }),
|
||||
];
|
||||
}
|
||||
|
||||
function render(props: { collapsed?: boolean } = {}) {
|
||||
return renderComponent({
|
||||
props: { collapsed: false, ...props },
|
||||
global: { plugins: [router], stubs: { RouterLink: false } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Test ids of the chat rows, without the ids of their state icons. */
|
||||
function rowTestIds(section: HTMLElement) {
|
||||
return within(section)
|
||||
.getAllByTestId(/^instance-ai-thread-(?!state-)/)
|
||||
.map((row) => row.getAttribute('data-test-id'));
|
||||
}
|
||||
|
||||
function rowLabels(section: HTMLElement) {
|
||||
return within(section)
|
||||
.getAllByRole('menuitem')
|
||||
.map((item) => item.getAttribute('aria-label'));
|
||||
}
|
||||
|
||||
describe('AssistantChatsSection', () => {
|
||||
beforeEach(() => {
|
||||
storage.clear();
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: vi.fn((key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
}),
|
||||
});
|
||||
createTestingPinia();
|
||||
resetThreadLastViewedState();
|
||||
useUsersStore().currentUserId = 'user-1';
|
||||
instanceAiStore = mockedStore(useInstanceAiStore);
|
||||
router = createTestRouter();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('with experience modes off', () => {
|
||||
it('lists the recent chats as before, without state icons', () => {
|
||||
configureInstanceAi();
|
||||
instanceAiStore.threads = chatsInEveryState();
|
||||
|
||||
const { getByTestId, queryAllByTestId, getByRole, getAllByRole } = render();
|
||||
|
||||
const section = getByTestId('instance-ai-sidebar-chats');
|
||||
expect(rowTestIds(section)).toEqual([
|
||||
'instance-ai-thread-waiting',
|
||||
'instance-ai-thread-busy',
|
||||
'instance-ai-thread-broken',
|
||||
'instance-ai-thread-unseen',
|
||||
'instance-ai-thread-seen',
|
||||
]);
|
||||
expect(rowLabels(section)).toEqual([
|
||||
'Approve invoice',
|
||||
'Build CRM sync',
|
||||
'Fix Slack alert',
|
||||
'Weekly report',
|
||||
'Team digest',
|
||||
]);
|
||||
expect(queryAllByTestId(/^instance-ai-thread-state-/)).toEqual([]);
|
||||
expect(getByRole('button', { name: 'Chats' })).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(getAllByRole('link').map((link) => link.textContent?.trim())).toEqual(['View all']);
|
||||
});
|
||||
|
||||
it('links each row to its chat and "View all" to the chat history', () => {
|
||||
configureInstanceAi();
|
||||
instanceAiStore.threads = [chat('a', 'First chat')];
|
||||
|
||||
const { getByRole } = render();
|
||||
|
||||
expect(getByRole('menuitem', { name: 'First chat' })).toHaveAttribute('href', '/assistant/a');
|
||||
expect(getByRole('link', { name: 'View all' })).toHaveAttribute('href', '/assistant/history');
|
||||
});
|
||||
|
||||
it('shows the five most recent chats and keeps the open chat listed', async () => {
|
||||
configureInstanceAi();
|
||||
instanceAiStore.threads = Array.from({ length: 7 }, (_, index) =>
|
||||
chat(`thread-${index}`, `Chat ${index}`),
|
||||
);
|
||||
await router.push({ name: INSTANCE_AI_THREAD_VIEW, params: { threadId: 'thread-6' } });
|
||||
|
||||
const { getByTestId } = render();
|
||||
|
||||
expect(rowLabels(getByTestId('instance-ai-sidebar-chats'))).toEqual([
|
||||
'Chat 0',
|
||||
'Chat 1',
|
||||
'Chat 2',
|
||||
'Chat 3',
|
||||
'Chat 6',
|
||||
]);
|
||||
});
|
||||
|
||||
it('collapses the list, and remembers it', async () => {
|
||||
configureInstanceAi();
|
||||
instanceAiStore.threads = [chat('a', 'First chat')];
|
||||
const { getByRole, queryByRole } = render();
|
||||
|
||||
await userEvent.click(getByRole('button', { name: 'Chats' }));
|
||||
|
||||
expect(getByRole('button', { name: 'Chats' })).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(queryByRole('menuitem', { name: 'First chat' })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(storage.get(COLLAPSED_KEY)).toBe('true'));
|
||||
});
|
||||
|
||||
it('starts collapsed when the user collapsed it before', () => {
|
||||
storage.set(COLLAPSED_KEY, 'true');
|
||||
configureInstanceAi();
|
||||
instanceAiStore.threads = [chat('a', 'First chat')];
|
||||
|
||||
const { getByRole, queryByRole } = render();
|
||||
|
||||
expect(getByRole('button', { name: 'Chats' })).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(queryByRole('menuitem')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the sidebar is collapsed', { collapsed: true }, true, 1],
|
||||
['there is no chat', { collapsed: false }, true, 0],
|
||||
['Instance AI is not available', { collapsed: false }, false, 1],
|
||||
])('renders nothing when %s', (_, props, available, chatCount) => {
|
||||
configureInstanceAi({ available });
|
||||
instanceAiStore.threads = [chat('a', 'First chat')].slice(0, chatCount);
|
||||
|
||||
const { queryByTestId } = render(props);
|
||||
|
||||
expect(queryByTestId('instance-ai-sidebar-chats')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads the chats while the sidebar is collapsed, and again when the tab is visible', () => {
|
||||
configureInstanceAi();
|
||||
|
||||
render({ collapsed: true });
|
||||
expect(instanceAiStore.loadThreads).toHaveBeenCalledTimes(1);
|
||||
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
expect(instanceAiStore.loadThreads).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not load the chats when Instance AI is not available', () => {
|
||||
configureInstanceAi({ available: false });
|
||||
|
||||
render();
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
|
||||
expect(instanceAiStore.loadThreads).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with experience modes on', () => {
|
||||
beforeEach(() => {
|
||||
configureInstanceAi({ experienceModes: true });
|
||||
storage.set(LAST_VIEWED_KEY, JSON.stringify({ unseen: T0, seen: T1 }));
|
||||
});
|
||||
|
||||
it('names the state of each chat in its row label', () => {
|
||||
instanceAiStore.threads = chatsInEveryState();
|
||||
|
||||
const { getByTestId } = render();
|
||||
|
||||
const section = getByTestId('instance-ai-sidebar-chats');
|
||||
expect(rowLabels(section)).toEqual([
|
||||
'Approve invoice, Waiting for you',
|
||||
'Build CRM sync, Working',
|
||||
'Fix Slack alert, Failed',
|
||||
'Weekly report, Ready to review',
|
||||
'Team digest',
|
||||
]);
|
||||
expect(rowTestIds(section)).toEqual([
|
||||
'instance-ai-thread-waiting',
|
||||
'instance-ai-thread-busy',
|
||||
'instance-ai-thread-broken',
|
||||
'instance-ai-thread-unseen',
|
||||
'instance-ai-thread-seen',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows a state icon for each chat that needs a look, and none for a chat the user saw', () => {
|
||||
instanceAiStore.threads = chatsInEveryState();
|
||||
|
||||
const { getAllByTestId } = render();
|
||||
|
||||
expect(
|
||||
getAllByTestId(/^instance-ai-thread-state-/).map((icon) => icon.dataset.testId),
|
||||
).toEqual([
|
||||
'instance-ai-thread-state-waiting',
|
||||
'instance-ai-thread-state-busy',
|
||||
'instance-ai-thread-state-broken',
|
||||
'instance-ai-thread-state-unseen',
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['waiting', 'Waiting for you'],
|
||||
['busy', 'Working'],
|
||||
['broken', 'Failed'],
|
||||
['unseen', 'Ready to review'],
|
||||
])('explains the icon of the %s chat in a tooltip', async (id, label) => {
|
||||
instanceAiStore.threads = chatsInEveryState();
|
||||
const { getByTestId } = render();
|
||||
|
||||
await hoverTooltipTrigger(getByTestId(`instance-ai-thread-state-${id}`));
|
||||
|
||||
await waitFor(() => expect(getTooltip()).toHaveTextContent(label));
|
||||
});
|
||||
|
||||
it('keeps the state icons out of the tab order and away from screen readers', () => {
|
||||
instanceAiStore.threads = chatsInEveryState();
|
||||
|
||||
const { getByTestId, getAllByRole } = render();
|
||||
|
||||
const icon = getByTestId('instance-ai-thread-state-waiting');
|
||||
expect(icon).toHaveAttribute('tabindex', '-1');
|
||||
expect(icon).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(getAllByRole('link').map((link) => link.textContent?.trim())).toEqual(['View all']);
|
||||
});
|
||||
|
||||
it('opens the chat when the user clicks its state icon', async () => {
|
||||
instanceAiStore.threads = chatsInEveryState();
|
||||
const { getByTestId } = render();
|
||||
|
||||
await userEvent.click(getByTestId('instance-ai-thread-state-unseen'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(router.currentRoute.value).toMatchObject({
|
||||
name: INSTANCE_AI_THREAD_VIEW,
|
||||
params: { threadId: 'unseen' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('removes the "Ready to review" icon once the chat view marks the chat as seen', async () => {
|
||||
instanceAiStore.threads = [
|
||||
chat('unseen', 'Weekly report', { state: 'idle', lastActivityAt: T1 }),
|
||||
];
|
||||
const { getByRole, queryByTestId } = render();
|
||||
expect(getByRole('menuitem', { name: 'Weekly report, Ready to review' })).toBeVisible();
|
||||
|
||||
useThreadLastViewed().markViewed('unseen', T1);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(queryByTestId('instance-ai-thread-state-unseen')).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(getByRole('menuitem', { name: 'Weekly report' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows "Ready to review" for an idle chat the user never opened', () => {
|
||||
storage.clear();
|
||||
instanceAiStore.threads = [
|
||||
chat('new', 'Fresh result', { state: 'idle', lastActivityAt: T0 }),
|
||||
];
|
||||
|
||||
const { getByRole } = render();
|
||||
|
||||
expect(getByRole('menuitem', { name: 'Fresh result, Ready to review' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows no state for a chat the server sent without one', () => {
|
||||
instanceAiStore.threads = [chat('older', 'Old chat')];
|
||||
|
||||
const { getByRole, queryByTestId } = render();
|
||||
|
||||
expect(getByRole('menuitem', { name: 'Old chat' })).toBeVisible();
|
||||
expect(queryByTestId('instance-ai-thread-state-older')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
INSTANCE_AI_THREAD_SERVER_STATES,
|
||||
type InstanceAiThreadOverview,
|
||||
type InstanceAiThreadServerState,
|
||||
} from '@n8n/api-types';
|
||||
import { threadDisplayState, type ThreadDisplayState } from '../threadDisplayState';
|
||||
|
||||
const BASE_TIME = Date.parse('2026-03-01T10:00:00.000Z');
|
||||
const at = (seconds: number) => new Date(BASE_TIME + seconds * 1000).toISOString();
|
||||
|
||||
// A small range, so that equal times happen often.
|
||||
const validTimeArb = fc.integer({ min: 0, max: 20 }).map(at);
|
||||
const timeArb = fc.option(fc.oneof(validTimeArb, fc.constantFrom('', 'not a date', '2026-13-45')), {
|
||||
nil: undefined,
|
||||
});
|
||||
const serverStateArb = fc.constantFrom(...INSTANCE_AI_THREAD_SERVER_STATES);
|
||||
// Valid states, near misses and values of other types, as a newer or older server could send them.
|
||||
const anyStateArb = fc.oneof(
|
||||
serverStateArb,
|
||||
fc.constantFrom(undefined, '', 'Idle', 'ready', 'done', 'needs_you'),
|
||||
fc.anything(),
|
||||
);
|
||||
const summaryArb = fc.record({
|
||||
state: anyStateArb,
|
||||
needsInput: fc.option(fc.boolean(), { nil: undefined }),
|
||||
lastActivityAt: timeArb,
|
||||
});
|
||||
|
||||
/** Builds a summary from untyped values, like a payload the editor did not validate yet. */
|
||||
function summaryFrom(values: { state: unknown; needsInput?: boolean; lastActivityAt?: string }) {
|
||||
return values as InstanceAiThreadOverview;
|
||||
}
|
||||
|
||||
describe('threadDisplayState', () => {
|
||||
it.each<[InstanceAiThreadServerState, ThreadDisplayState]>([
|
||||
['needs-you', 'needs-you'],
|
||||
['working', 'working'],
|
||||
['failed', 'failed'],
|
||||
])('shows the server state %s as %s, whatever the viewer saw', (state, expected) => {
|
||||
expect(threadDisplayState({ state, lastActivityAt: at(10) }, at(20))).toBe(expected);
|
||||
expect(threadDisplayState({ state, lastActivityAt: at(20) }, at(10))).toBe(expected);
|
||||
expect(threadDisplayState({ state })).toBe(expected);
|
||||
});
|
||||
|
||||
describe('an idle chat', () => {
|
||||
it('is ready when it had activity after the viewer last opened it', () => {
|
||||
expect(threadDisplayState({ state: 'idle', lastActivityAt: at(5) }, at(4))).toBe('ready');
|
||||
});
|
||||
|
||||
it('is ready when the activity is one millisecond after the last view', () => {
|
||||
const viewed = '2026-03-01T10:00:00.000Z';
|
||||
const activity = '2026-03-01T10:00:00.001Z';
|
||||
expect(threadDisplayState({ state: 'idle', lastActivityAt: activity }, viewed)).toBe('ready');
|
||||
});
|
||||
|
||||
it('is ready when the viewer never opened it', () => {
|
||||
expect(threadDisplayState({ state: 'idle', lastActivityAt: at(5) })).toBe('ready');
|
||||
expect(threadDisplayState({ state: 'idle', lastActivityAt: at(5) }, undefined)).toBe('ready');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['at the same time as', 5],
|
||||
['after', 6],
|
||||
])('is done when the viewer opened it %s the last activity', (_, viewedSecond) => {
|
||||
expect(threadDisplayState({ state: 'idle', lastActivityAt: at(5) }, at(viewedSecond))).toBe(
|
||||
'done',
|
||||
);
|
||||
});
|
||||
|
||||
it('compares instants, not the text of the times', () => {
|
||||
const summary = { state: 'idle' as const, lastActivityAt: '2026-03-01T12:00:00+02:00' };
|
||||
expect(threadDisplayState(summary, '2026-03-01T10:00:00Z')).toBe('done');
|
||||
expect(threadDisplayState(summary, '2026-03-01T09:59:59.999Z')).toBe('ready');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the activity time is missing', undefined, at(5)],
|
||||
['the activity time is not a date', 'yesterday', at(5)],
|
||||
['the activity time is empty', '', at(5)],
|
||||
['the last view is not a date', at(5), 'yesterday'],
|
||||
['the last view is empty', at(5), ''],
|
||||
])('is ready when %s', (_, lastActivityAt, lastViewedAt) => {
|
||||
expect(threadDisplayState({ state: 'idle', lastActivityAt }, lastViewedAt)).toBe('ready');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a summary without a valid state', () => {
|
||||
it.each([undefined, null, '', 'Idle', 'ready', 'done', 'needs_you', 1, {}])(
|
||||
'gives no state for %j',
|
||||
(state) => {
|
||||
expect(
|
||||
threadDisplayState(summaryFrom({ state, lastActivityAt: at(5) }), at(1)),
|
||||
).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it('still shows needs-you when the chat waits for the viewer', () => {
|
||||
expect(threadDisplayState({ needsInput: true })).toBe('needs-you');
|
||||
});
|
||||
});
|
||||
|
||||
it.each<InstanceAiThreadServerState>(['working', 'failed', 'idle'])(
|
||||
'shows needs-you when the chat waits for the viewer, even with the state %s',
|
||||
(state) => {
|
||||
expect(threadDisplayState({ state, needsInput: true, lastActivityAt: at(1) }, at(9))).toBe(
|
||||
'needs-you',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('shows needs-you for the needs-you state without the needs-input flag', () => {
|
||||
expect(threadDisplayState({ state: 'needs-you', needsInput: false })).toBe('needs-you');
|
||||
});
|
||||
|
||||
describe('properties', () => {
|
||||
it('always shows needs-you when the chat waits for the viewer', () => {
|
||||
fc.assert(
|
||||
fc.property(summaryArb, timeArb, (summary, lastViewedAt) => {
|
||||
const result = threadDisplayState(
|
||||
summaryFrom({ ...summary, needsInput: true }),
|
||||
lastViewedAt,
|
||||
);
|
||||
expect(result).toBe('needs-you');
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('always shows needs-you for the needs-you state', () => {
|
||||
fc.assert(
|
||||
fc.property(summaryArb, timeArb, (summary, lastViewedAt) => {
|
||||
const result = threadDisplayState(
|
||||
summaryFrom({ ...summary, state: 'needs-you' }),
|
||||
lastViewedAt,
|
||||
);
|
||||
expect(result).toBe('needs-you');
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('is never ready when the viewer opened the chat at or after its last activity', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
serverStateArb,
|
||||
fc.option(fc.boolean(), { nil: undefined }),
|
||||
fc.integer({ min: 0, max: 20 }),
|
||||
fc.integer({ min: 0, max: 20 }),
|
||||
(state, needsInput, activitySecond, viewedOffset) => {
|
||||
const result = threadDisplayState(
|
||||
{ state, needsInput, lastActivityAt: at(activitySecond) },
|
||||
at(activitySecond + viewedOffset),
|
||||
);
|
||||
expect(result).not.toBe('ready');
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('is ready for an idle chat with activity after the last view', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 0, max: 20 }),
|
||||
fc.integer({ min: 1, max: 20 }),
|
||||
fc.constantFrom(undefined, false),
|
||||
(viewedSecond, gap, needsInput) => {
|
||||
const summary = {
|
||||
state: 'idle' as const,
|
||||
needsInput,
|
||||
lastActivityAt: at(viewedSecond + gap),
|
||||
};
|
||||
expect(threadDisplayState(summary, at(viewedSecond))).toBe('ready');
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('gives a known state for each valid server state, and nothing for any other value', () => {
|
||||
fc.assert(
|
||||
fc.property(summaryArb, timeArb, (summary, lastViewedAt) => {
|
||||
const result = threadDisplayState(summaryFrom(summary), lastViewedAt);
|
||||
const hasValidState = INSTANCE_AI_THREAD_SERVER_STATES.some((s) => s === summary.state);
|
||||
if (summary.needsInput === true || hasValidState) {
|
||||
expect(['needs-you', 'working', 'failed', 'ready', 'done']).toContain(result);
|
||||
} else {
|
||||
expect(result).toBeUndefined();
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps working and failed as they are when the chat does not wait for the viewer', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom<InstanceAiThreadServerState>('working', 'failed'),
|
||||
fc.constantFrom(undefined, false),
|
||||
timeArb,
|
||||
timeArb,
|
||||
(state, needsInput, lastActivityAt, lastViewedAt) => {
|
||||
expect(threadDisplayState({ state, needsInput, lastActivityAt }, lastViewedAt)).toBe(
|
||||
state,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,375 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { computed, effectScope, nextTick, ref } from 'vue';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import type { InstanceAiThreadSummary } from '@n8n/api-types';
|
||||
import { useUsersStore } from '@n8n/stores/users.store';
|
||||
import { useInstanceAiStore } from '../../instanceAi.store';
|
||||
import {
|
||||
MAX_LAST_VIEWED_ENTRIES,
|
||||
resetThreadLastViewedState,
|
||||
useMarkThreadViewed,
|
||||
useThreadLastViewed,
|
||||
} from '../useThreadLastViewed';
|
||||
|
||||
const NOW = '2026-03-01T10:00:00.000Z';
|
||||
const keyFor = (userId: string) => `n8n:instance-ai:last-viewed:${userId}`;
|
||||
const secondsAfterNow = (seconds: number) =>
|
||||
new Date(Date.parse(NOW) + seconds * 1000).toISOString();
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
|
||||
function createStorageStub() {
|
||||
return {
|
||||
getItem: vi.fn((key: string): string | null => storage.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
storage.delete(key);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
let storageStub: ReturnType<typeof createStorageStub>;
|
||||
|
||||
/** A fresh page: Map-backed storage, a fixed clock and a signed-in user. */
|
||||
function setUpPage() {
|
||||
createTestingPinia();
|
||||
storage.clear();
|
||||
storageStub = createStorageStub();
|
||||
vi.stubGlobal('localStorage', storageStub);
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(NOW);
|
||||
resetThreadLastViewedState();
|
||||
signIn('user-1');
|
||||
}
|
||||
|
||||
function tearDownPage() {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
function signIn(id: string | null) {
|
||||
useUsersStore().currentUserId = id;
|
||||
}
|
||||
|
||||
function storedViews(userId: string): unknown {
|
||||
const raw = storage.get(keyFor(userId));
|
||||
return raw === undefined ? undefined : JSON.parse(raw);
|
||||
}
|
||||
|
||||
/** Starts a new page: the shared state is empty and the next read loads the stored copy. */
|
||||
function reloadPage() {
|
||||
resetThreadLastViewedState();
|
||||
}
|
||||
|
||||
describe('useThreadLastViewed', () => {
|
||||
beforeEach(setUpPage);
|
||||
afterEach(tearDownPage);
|
||||
|
||||
it('stores the view time for each user and reads it back after a reload', () => {
|
||||
const { markViewed, lastViewedAt } = useThreadLastViewed();
|
||||
|
||||
markViewed('thread-a');
|
||||
|
||||
expect(lastViewedAt('thread-a')).toBe(NOW);
|
||||
expect(storedViews('user-1')).toEqual({ 'thread-a': NOW });
|
||||
|
||||
reloadPage();
|
||||
expect(useThreadLastViewed().lastViewedAt('thread-a')).toBe(NOW);
|
||||
expect(lastViewedAt('thread-b')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the views of each user apart', () => {
|
||||
const { markViewed, lastViewedAt } = useThreadLastViewed();
|
||||
markViewed('thread-a');
|
||||
|
||||
signIn('user-2');
|
||||
|
||||
expect(lastViewedAt('thread-a')).toBeUndefined();
|
||||
markViewed('thread-b');
|
||||
expect(storedViews('user-2')).toEqual({ 'thread-b': NOW });
|
||||
expect(storedViews('user-1')).toEqual({ 'thread-a': NOW });
|
||||
});
|
||||
|
||||
it('keeps the other views when it marks a chat again', () => {
|
||||
storage.set(keyFor('user-1'), JSON.stringify({ old: '2026-02-01T00:00:00.000Z' }));
|
||||
const { markViewed } = useThreadLastViewed();
|
||||
|
||||
markViewed('thread-a');
|
||||
vi.setSystemTime(secondsAfterNow(30));
|
||||
markViewed('thread-a');
|
||||
|
||||
expect(storedViews('user-1')).toEqual({
|
||||
'thread-a': secondsAfterNow(30),
|
||||
old: '2026-02-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads and stores nothing without a signed-in user', () => {
|
||||
signIn(null);
|
||||
const { markViewed, lastViewedAt } = useThreadLastViewed();
|
||||
|
||||
markViewed('thread-a');
|
||||
|
||||
expect(lastViewedAt('thread-a')).toBeUndefined();
|
||||
expect(storageStub.getItem).not.toHaveBeenCalled();
|
||||
expect(storageStub.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('the time it stores', () => {
|
||||
it('is the activity time when the server clock is ahead of this one', () => {
|
||||
const { markViewed, lastViewedAt } = useThreadLastViewed();
|
||||
|
||||
markViewed('thread-a', secondsAfterNow(90));
|
||||
|
||||
expect(lastViewedAt('thread-a')).toBe(secondsAfterNow(90));
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an older activity time', secondsAfterNow(-90)],
|
||||
['no activity time', undefined],
|
||||
['an invalid activity time', 'not a date'],
|
||||
])('is now for %s', (_, seenActivityAt) => {
|
||||
const { markViewed, lastViewedAt } = useThreadLastViewed();
|
||||
|
||||
markViewed('thread-a', seenActivityAt);
|
||||
|
||||
expect(lastViewedAt('thread-a')).toBe(NOW);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stored data it cannot use', () => {
|
||||
it('drops entries without a valid time and keeps the others', () => {
|
||||
storage.set(
|
||||
keyFor('user-1'),
|
||||
JSON.stringify({ good: NOW, text: 'yesterday', empty: '', count: 5, nothing: null }),
|
||||
);
|
||||
|
||||
const { lastViewedAt } = useThreadLastViewed();
|
||||
|
||||
expect(lastViewedAt('good')).toBe(NOW);
|
||||
for (const id of ['text', 'empty', 'count', 'nothing']) {
|
||||
expect(lastViewedAt(id)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['{broken', '[1, 2]', 'null', '"text"', '42'])('ignores the stored value %s', (raw) => {
|
||||
storage.set(keyFor('user-1'), raw);
|
||||
const { markViewed, lastViewedAt } = useThreadLastViewed();
|
||||
|
||||
expect(lastViewedAt('thread-a')).toBeUndefined();
|
||||
markViewed('thread-a');
|
||||
expect(storedViews('user-1')).toEqual({ 'thread-a': NOW });
|
||||
});
|
||||
|
||||
it('does not read a key that the object inherits', () => {
|
||||
const { lastViewedAt } = useThreadLastViewed();
|
||||
|
||||
expect(lastViewedAt('constructor')).toBeUndefined();
|
||||
expect(lastViewedAt('__proto__')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when storage fails', () => {
|
||||
it('returns no view when reading throws, and keeps a new view for this page', () => {
|
||||
storageStub.getItem.mockImplementation(() => {
|
||||
throw new Error('Storage is blocked');
|
||||
});
|
||||
storageStub.setItem.mockImplementation(() => {
|
||||
throw new Error('Storage is full');
|
||||
});
|
||||
const { markViewed, lastViewedAt } = useThreadLastViewed();
|
||||
|
||||
expect(lastViewedAt('thread-a')).toBeUndefined();
|
||||
expect(() => markViewed('thread-a')).not.toThrow();
|
||||
expect(lastViewedAt('thread-a')).toBe(NOW);
|
||||
});
|
||||
|
||||
it('returns no view when the browser refuses access to storage', () => {
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
throw new DOMException('Access is denied', 'SecurityError');
|
||||
},
|
||||
});
|
||||
try {
|
||||
const { markViewed, lastViewedAt } = useThreadLastViewed();
|
||||
|
||||
expect(lastViewedAt('thread-a')).toBeUndefined();
|
||||
expect(() => markViewed('thread-a')).not.toThrow();
|
||||
expect(lastViewedAt('thread-a')).toBe(NOW);
|
||||
} finally {
|
||||
if (original) Object.defineProperty(globalThis, 'localStorage', original);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(`the limit of ${MAX_LAST_VIEWED_ENTRIES} entries`, () => {
|
||||
// `thread-0` is the newest view. The map lists the oldest view first, so that only a
|
||||
// sort by time (not the order of the stored keys) finds the views to keep.
|
||||
const olderViews = (count: number) =>
|
||||
Object.fromEntries(
|
||||
Array.from({ length: count }, (_, index) => [
|
||||
`thread-${index}`,
|
||||
secondsAfterNow(-index - 1),
|
||||
]).reverse(),
|
||||
);
|
||||
|
||||
it('drops the oldest view when a new chat is marked', () => {
|
||||
storage.set(keyFor('user-1'), JSON.stringify(olderViews(MAX_LAST_VIEWED_ENTRIES)));
|
||||
const { markViewed } = useThreadLastViewed();
|
||||
|
||||
markViewed('thread-new');
|
||||
|
||||
const stored = storedViews('user-1') as Record<string, string>;
|
||||
expect(Object.keys(stored)).toHaveLength(MAX_LAST_VIEWED_ENTRIES);
|
||||
expect(stored['thread-new']).toBe(NOW);
|
||||
expect(stored).not.toHaveProperty(`thread-${MAX_LAST_VIEWED_ENTRIES - 1}`);
|
||||
expect(stored).toHaveProperty(`thread-${MAX_LAST_VIEWED_ENTRIES - 2}`);
|
||||
});
|
||||
|
||||
it('keeps the limit when it marks a chat that is already stored', () => {
|
||||
storage.set(keyFor('user-1'), JSON.stringify(olderViews(MAX_LAST_VIEWED_ENTRIES)));
|
||||
const { markViewed } = useThreadLastViewed();
|
||||
|
||||
markViewed('thread-150');
|
||||
|
||||
const stored = storedViews('user-1') as Record<string, string>;
|
||||
expect(Object.keys(stored)).toHaveLength(MAX_LAST_VIEWED_ENTRIES);
|
||||
expect(stored['thread-150']).toBe(NOW);
|
||||
expect(stored).toHaveProperty(`thread-${MAX_LAST_VIEWED_ENTRIES - 1}`);
|
||||
});
|
||||
|
||||
it('keeps only the newest views from a larger stored map', () => {
|
||||
storage.set(keyFor('user-1'), JSON.stringify(olderViews(MAX_LAST_VIEWED_ENTRIES + 50)));
|
||||
const { lastViewedAt } = useThreadLastViewed();
|
||||
|
||||
expect(lastViewedAt('thread-0')).toBe(secondsAfterNow(-1));
|
||||
expect(lastViewedAt(`thread-${MAX_LAST_VIEWED_ENTRIES - 1}`)).toBeDefined();
|
||||
expect(lastViewedAt(`thread-${MAX_LAST_VIEWED_ENTRIES}`)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('updates every reader at once when one of them marks a chat', () => {
|
||||
const sidebar = useThreadLastViewed();
|
||||
const threadView = useThreadLastViewed();
|
||||
const shown = computed(() => sidebar.lastViewedAt('thread-a'));
|
||||
expect(shown.value).toBeUndefined();
|
||||
|
||||
threadView.markViewed('thread-a');
|
||||
|
||||
expect(shown.value).toBe(NOW);
|
||||
});
|
||||
|
||||
it('reads the views again after another tab stores them', () => {
|
||||
const { lastViewedAt } = useThreadLastViewed();
|
||||
const shown = computed(() => lastViewedAt('thread-a'));
|
||||
expect(shown.value).toBeUndefined();
|
||||
|
||||
storage.set(keyFor('user-1'), JSON.stringify({ 'thread-a': NOW }));
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: keyFor('user-1') }));
|
||||
expect(shown.value).toBe(NOW);
|
||||
|
||||
// A key of another feature, with the same length and the same user ID at the end.
|
||||
storage.clear();
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', { key: 'n8n:instance-ai:other-thing:user-1' }),
|
||||
);
|
||||
expect(shown.value).toBe(NOW);
|
||||
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: null }));
|
||||
expect(shown.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps reading views from other tabs after another reader unmounts', () => {
|
||||
const threadViewScope = effectScope();
|
||||
threadViewScope.run(() => useThreadLastViewed());
|
||||
const sidebarScope = effectScope();
|
||||
const sidebar = sidebarScope.run(() => useThreadLastViewed());
|
||||
const shown = computed(() => sidebar?.lastViewedAt('thread-a'));
|
||||
expect(shown.value).toBeUndefined();
|
||||
|
||||
threadViewScope.stop();
|
||||
storage.set(keyFor('user-1'), JSON.stringify({ 'thread-a': NOW }));
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: keyFor('user-1') }));
|
||||
|
||||
expect(shown.value).toBe(NOW);
|
||||
sidebarScope.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useMarkThreadViewed', () => {
|
||||
function thread(id: string, lastActivityAt?: string): InstanceAiThreadSummary {
|
||||
return { id, title: id, createdAt: NOW, updatedAt: NOW, state: 'idle', lastActivityAt };
|
||||
}
|
||||
|
||||
function mountView(threadId: string, working: boolean) {
|
||||
const scope = effectScope();
|
||||
const id = ref(threadId);
|
||||
const isWorking = ref(working);
|
||||
scope.run(() => useMarkThreadViewed(() => id.value, isWorking));
|
||||
return { id, isWorking, stop: () => scope.stop() };
|
||||
}
|
||||
|
||||
beforeEach(setUpPage);
|
||||
afterEach(tearDownPage);
|
||||
|
||||
it('marks the chat as seen when the user enters it', () => {
|
||||
const view = mountView('thread-a', false);
|
||||
|
||||
expect(useThreadLastViewed().lastViewedAt('thread-a')).toBe(NOW);
|
||||
view.stop();
|
||||
});
|
||||
|
||||
it('waits until the chat stops working, then marks it', async () => {
|
||||
const view = mountView('thread-a', true);
|
||||
const { lastViewedAt } = useThreadLastViewed();
|
||||
expect(lastViewedAt('thread-a')).toBeUndefined();
|
||||
|
||||
vi.setSystemTime(secondsAfterNow(40));
|
||||
view.isWorking.value = false;
|
||||
await nextTick();
|
||||
|
||||
expect(lastViewedAt('thread-a')).toBe(secondsAfterNow(40));
|
||||
view.stop();
|
||||
});
|
||||
|
||||
it('marks the chat again when new activity arrives while it is open', async () => {
|
||||
const store = useInstanceAiStore();
|
||||
const otherChat = thread('thread-other', secondsAfterNow(600));
|
||||
store.threads = [otherChat, thread('thread-a', NOW)];
|
||||
const view = mountView('thread-a', false);
|
||||
const { lastViewedAt } = useThreadLastViewed();
|
||||
|
||||
// The server clock is ahead: the activity time is later than this clock.
|
||||
store.threads = [otherChat, thread('thread-a', secondsAfterNow(120))];
|
||||
await nextTick();
|
||||
|
||||
expect(lastViewedAt('thread-a')).toBe(secondsAfterNow(120));
|
||||
expect(lastViewedAt('thread-other')).toBeUndefined();
|
||||
view.stop();
|
||||
});
|
||||
|
||||
it('marks the next chat when the route changes to it', async () => {
|
||||
const view = mountView('thread-a', false);
|
||||
|
||||
view.id.value = 'thread-b';
|
||||
await nextTick();
|
||||
|
||||
expect(useThreadLastViewed().lastViewedAt('thread-b')).toBe(NOW);
|
||||
view.stop();
|
||||
});
|
||||
|
||||
it('does not mark the chat while it works', async () => {
|
||||
const store = useInstanceAiStore();
|
||||
const view = mountView('thread-a', true);
|
||||
|
||||
store.threads = [thread('thread-a', secondsAfterNow(5))];
|
||||
await nextTick();
|
||||
|
||||
expect(storageStub.setItem).not.toHaveBeenCalled();
|
||||
view.stop();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { z } from 'zod';
|
||||
import {
|
||||
INSTANCE_AI_THREAD_SERVER_STATES,
|
||||
type InstanceAiThreadOverview,
|
||||
type InstanceAiThreadServerState,
|
||||
} from '@n8n/api-types';
|
||||
|
||||
/**
|
||||
* What a chat needs from this viewer. The server sends `idle` and the editor splits it,
|
||||
* because only this browser knows when the viewer last opened the chat.
|
||||
*/
|
||||
export type ThreadDisplayState = Exclude<InstanceAiThreadServerState, 'idle'> | 'ready' | 'done';
|
||||
|
||||
const serverStateSchema = z.enum(INSTANCE_AI_THREAD_SERVER_STATES);
|
||||
|
||||
/** Returns the time in milliseconds, or `undefined` for a missing or invalid date. */
|
||||
export function toTime(value: string | undefined): number | undefined {
|
||||
const time = new Date(value ?? Number.NaN).getTime();
|
||||
return Number.isNaN(time) ? undefined : time;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same rule as `hasUnseenActivity` on the server (thread-overview/thread-state.ts), so that both
|
||||
* sides agree. Without two valid times we cannot prove that the viewer saw the last activity.
|
||||
*/
|
||||
function hasUnseenActivity(lastActivityAt: string | undefined, lastViewedAt: string | undefined) {
|
||||
const activityTime = toTime(lastActivityAt);
|
||||
const viewedTime = toTime(lastViewedAt);
|
||||
if (activityTime === undefined || viewedTime === undefined) return true;
|
||||
return activityTime > viewedTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `undefined` when the summary has no valid state: the server sets it only for the
|
||||
* first 50 threads. A pending approval or question always wins, as on the server.
|
||||
*/
|
||||
export function threadDisplayState(
|
||||
summary: InstanceAiThreadOverview,
|
||||
lastViewedAt?: string,
|
||||
): ThreadDisplayState | undefined {
|
||||
if (summary.needsInput === true) return 'needs-you';
|
||||
// `data` is undefined for a missing or unknown state.
|
||||
const state = serverStateSchema.safeParse(summary.state).data;
|
||||
if (state !== 'idle') return state;
|
||||
return hasUnseenActivity(summary.lastActivityAt, lastViewedAt) ? 'ready' : 'done';
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import { computed, shallowReactive, toRaw, watch, type Ref } from 'vue';
|
||||
import { z } from 'zod';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { useUsersStore } from '@n8n/stores/users.store';
|
||||
import { useInstanceAiStore } from '../instanceAi.store';
|
||||
import { toTime } from './threadDisplayState';
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'n8n:instance-ai:last-viewed:';
|
||||
|
||||
/** The oldest views go first, so that the stored map stays small. */
|
||||
export const MAX_LAST_VIEWED_ENTRIES = 200;
|
||||
|
||||
type LastViewedEntries = ReadonlyMap<string, string>;
|
||||
|
||||
const storedEntriesSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
// The sidebar and the thread view share one map for each user, so a view updates the sidebar
|
||||
// at once. The key is the user ID, because a new sign-in does not reload the page.
|
||||
const entriesByUser = shallowReactive(new Map<string, LastViewedEntries>());
|
||||
|
||||
/** Clears the shared state. Tests call this between cases. */
|
||||
export function resetThreadLastViewedState() {
|
||||
entriesByUser.clear();
|
||||
}
|
||||
|
||||
const storageKey = (userId: string) => `${STORAGE_KEY_PREFIX}${userId}`;
|
||||
|
||||
/** Keeps the newest views. Every value is a valid time here. */
|
||||
function keepNewest(entries: Array<[string, string]>): LastViewedEntries {
|
||||
const newestFirst = entries.toSorted(([, a], [, b]) => Date.parse(b) - Date.parse(a));
|
||||
return new Map(newestFirst.slice(0, MAX_LAST_VIEWED_ENTRIES));
|
||||
}
|
||||
|
||||
function readEntries(userId: string): LastViewedEntries {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey(userId));
|
||||
if (raw === null) return new Map();
|
||||
const parsed = storedEntriesSchema.safeParse(JSON.parse(raw));
|
||||
if (!parsed.success) return new Map();
|
||||
const valid = Object.entries(parsed.data).filter(
|
||||
(entry): entry is [string, string] =>
|
||||
typeof entry[1] === 'string' && toTime(entry[1]) !== undefined,
|
||||
);
|
||||
return keepNewest(valid);
|
||||
} catch {
|
||||
// Blocked storage or broken JSON: start again with no views.
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function writeEntries(userId: string, entries: LastViewedEntries) {
|
||||
try {
|
||||
localStorage.setItem(storageKey(userId), JSON.stringify(Object.fromEntries(entries)));
|
||||
} catch {
|
||||
// Blocked or full storage: the views last for this page session only.
|
||||
}
|
||||
}
|
||||
|
||||
function entriesFor(userId: string): LastViewedEntries {
|
||||
const cached = entriesByUser.get(userId);
|
||||
if (cached) return cached;
|
||||
const entries = readEntries(userId);
|
||||
// Reading the stored copy changes nothing that a render shows, so it does not notify readers.
|
||||
toRaw(entriesByUser).set(userId, entries);
|
||||
return entries;
|
||||
}
|
||||
|
||||
// Another tab wrote its views. Read them again on the next access.
|
||||
function onStorage(event: StorageEvent) {
|
||||
if (event.key === null) entriesByUser.clear();
|
||||
else if (event.key.startsWith(STORAGE_KEY_PREFIX)) {
|
||||
entriesByUser.delete(event.key.slice(STORAGE_KEY_PREFIX.length));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When the current user last opened each Assistant chat, kept in this browser.
|
||||
* Without a signed-in user, nothing is read or stored.
|
||||
*/
|
||||
export function useThreadLastViewed() {
|
||||
const usersStore = useUsersStore();
|
||||
// A new function for each caller: the DOM adds the same function only once, so the first
|
||||
// caller that unmounts would remove the listener of all the others.
|
||||
useEventListener(window, 'storage', (event) => onStorage(event));
|
||||
|
||||
function lastViewedAt(threadId: string): string | undefined {
|
||||
const userId = usersStore.currentUserId;
|
||||
return userId ? entriesFor(userId).get(threadId) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the later of now and `seenActivityAt`. The activity time comes from the server
|
||||
* clock, so a client clock that is behind cannot make a chat the user saw look unseen.
|
||||
*/
|
||||
function markViewed(threadId: string, seenActivityAt?: string) {
|
||||
const userId = usersStore.currentUserId;
|
||||
if (!userId) return;
|
||||
const time = Math.max(Date.now(), toTime(seenActivityAt) ?? Number.NEGATIVE_INFINITY);
|
||||
const others = [...entriesFor(userId)].filter(([id]) => id !== threadId);
|
||||
const next = keepNewest([[threadId, new Date(time).toISOString()], ...others]);
|
||||
entriesByUser.set(userId, next);
|
||||
writeEntries(userId, next);
|
||||
}
|
||||
|
||||
return { lastViewedAt, markViewed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the open chat as seen when the user enters it, when it stops working and when
|
||||
* new activity arrives while it stays open.
|
||||
*/
|
||||
export function useMarkThreadViewed(threadId: () => string, isWorking: Readonly<Ref<boolean>>) {
|
||||
const store = useInstanceAiStore();
|
||||
const { markViewed } = useThreadLastViewed();
|
||||
const lastActivityAt = computed(
|
||||
() => store.threads.find((thread) => thread.id === threadId())?.lastActivityAt,
|
||||
);
|
||||
|
||||
watch(
|
||||
[threadId, isWorking, lastActivityAt],
|
||||
([id, working, activityAt]) => {
|
||||
if (!working) markViewed(id, activityAt);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts" setup>
|
||||
import { useDocumentVisibility } from '@/app/composables/useDocumentVisibility';
|
||||
import { useGlobalEntityCreation } from '@/app/composables/useGlobalEntityCreation';
|
||||
import { VIEWS } from '@/app/constants';
|
||||
import { sourceControlEventBus } from '@/features/integrations/sourceControl.ee/sourceControl.eventBus';
|
||||
|
|
@ -8,7 +7,6 @@ import { useUsersStore } from '@n8n/stores/users.store';
|
|||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import { N8nIcon, N8nMenuItem, N8nText } from '@n8n/design-system';
|
||||
import type { IMenuItem } from '@n8n/design-system';
|
||||
import type { InstanceAiThreadSummary } from '@n8n/api-types';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { computed, onBeforeMount, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
|
@ -18,20 +16,15 @@ import type { ProjectListItem } from '../projects.types';
|
|||
import { CHAT_VIEW } from '@/features/ai/chatHub/constants';
|
||||
import { useFavoritesStore } from '@/app/stores/favorites.store';
|
||||
import { useFavoriteNavItems } from '../composables/useFavoriteNavItems';
|
||||
import {
|
||||
INSTANCE_AI_THREADS_VIEW,
|
||||
INSTANCE_AI_THREAD_VIEW,
|
||||
INSTANCE_AI_VIEW,
|
||||
} from '@/features/ai/instanceAi/constants';
|
||||
import { INSTANCE_AI_THREAD_VIEW, INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants';
|
||||
import { useInstanceAiAvailable } from '@/features/ai/instanceAi/composables/useInstanceAiAvailability';
|
||||
import { useInstanceAiStore } from '@/features/ai/instanceAi/instanceAi.store';
|
||||
import AssistantChatsSection from '@/features/ai/instanceAi/navigation/AssistantChatsSection.vue';
|
||||
import { WORKFLOW_REVIEW_REQUESTS_VIEW } from '@/features/workflow-reviews/constants';
|
||||
import { useWorkflowReviewsFeature } from '@/features/workflow-reviews/composables/useWorkflowReviewsFeature';
|
||||
|
||||
import { hasPermission } from '@/app/utils/rbac/permissions';
|
||||
|
||||
const PROJECTS_COLLAPSED_KEY = 'n8n:sidebar:projects-collapsed';
|
||||
const INSTANCE_AI_CHATS_COLLAPSED_KEY = 'n8n:sidebar:instance-ai-chats-collapsed';
|
||||
|
||||
type Props = {
|
||||
collapsed: boolean;
|
||||
|
|
@ -48,7 +41,6 @@ const projectsStore = useProjectsStore();
|
|||
const settingsStore = useSettingsStore();
|
||||
const usersStore = useUsersStore();
|
||||
const favoritesStore = useFavoritesStore();
|
||||
const instanceAiStore = useInstanceAiStore();
|
||||
|
||||
const {
|
||||
favoriteGroups,
|
||||
|
|
@ -76,32 +68,11 @@ const FAVORITES_COLLAPSED_KEY = computed(
|
|||
|
||||
const favoritesCollapsed = ref(localStorage.getItem(FAVORITES_COLLAPSED_KEY.value) === 'true');
|
||||
const projectsCollapsed = ref(localStorage.getItem(PROJECTS_COLLAPSED_KEY) === 'true');
|
||||
const instanceAiChatsCollapsed = ref(
|
||||
localStorage.getItem(INSTANCE_AI_CHATS_COLLAPSED_KEY) === 'true',
|
||||
);
|
||||
|
||||
// The recent chats read the store list; fetch it once the AI Assistant entry is shown.
|
||||
watch(
|
||||
isInstanceAiNavVisible,
|
||||
(visible) => {
|
||||
if (visible) void instanceAiStore.loadThreads();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Another tab can start a chat; refresh the list when the user comes back to this one.
|
||||
const { onDocumentVisible } = useDocumentVisibility();
|
||||
onDocumentVisible(() => {
|
||||
if (isInstanceAiNavVisible.value) void instanceAiStore.loadThreads();
|
||||
});
|
||||
|
||||
watch(favoritesCollapsed, (val) =>
|
||||
localStorage.setItem(FAVORITES_COLLAPSED_KEY.value, String(val)),
|
||||
);
|
||||
watch(projectsCollapsed, (val) => localStorage.setItem(PROJECTS_COLLAPSED_KEY, String(val)));
|
||||
watch(instanceAiChatsCollapsed, (val) =>
|
||||
localStorage.setItem(INSTANCE_AI_CHATS_COLLAPSED_KEY, String(val)),
|
||||
);
|
||||
|
||||
const home = computed<IMenuItem>(() => ({
|
||||
id: 'home',
|
||||
|
|
@ -156,28 +127,10 @@ const instanceAi = computed<IMenuItem>(() => ({
|
|||
}));
|
||||
|
||||
const isInstanceAiThreadView = computed(() => route.name === INSTANCE_AI_THREAD_VIEW);
|
||||
const recentInstanceAiThreads = computed(() => {
|
||||
const recent = instanceAiStore.threads.slice(0, 5);
|
||||
// Keep the open chat in the list when it is older than the five most recent
|
||||
// ones, e.g. opened from the history page or by URL.
|
||||
const openThreadId = isInstanceAiThreadView.value ? route.params.threadId : undefined;
|
||||
if (typeof openThreadId !== 'string' || recent.some((t) => t.id === openThreadId)) return recent;
|
||||
const openThread = instanceAiStore.threads.find((t) => t.id === openThreadId);
|
||||
return openThread ? [...recent.slice(0, 4), openThread] : recent;
|
||||
});
|
||||
const sidebarActiveTabId = computed(() =>
|
||||
isInstanceAiThreadView.value ? undefined : activeTabId.value,
|
||||
);
|
||||
|
||||
const getInstanceAiThreadMenuItem = (thread: InstanceAiThreadSummary): IMenuItem => ({
|
||||
id: `instance-ai-thread-${thread.id}`,
|
||||
icon: 'message-circle',
|
||||
label: thread.title,
|
||||
route: {
|
||||
to: { name: INSTANCE_AI_THREAD_VIEW, params: { threadId: thread.id } },
|
||||
},
|
||||
});
|
||||
|
||||
const { isWorkflowReviewsEnabled: isWorkflowReviewsNavVisible } = useWorkflowReviewsFeature();
|
||||
|
||||
const workflowReviews = computed<IMenuItem>(() => ({
|
||||
|
|
@ -313,42 +266,7 @@ onBeforeUnmount(() => {
|
|||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="isInstanceAiNavVisible && !props.collapsed && recentInstanceAiThreads.length > 0"
|
||||
:class="$style.instanceAiSidebar"
|
||||
data-test-id="instance-ai-sidebar-chats"
|
||||
>
|
||||
<div :class="$style.instanceAiChatsHeader">
|
||||
<button
|
||||
type="button"
|
||||
:class="$style.instanceAiChatsToggle"
|
||||
:aria-expanded="!instanceAiChatsCollapsed"
|
||||
@click="instanceAiChatsCollapsed = !instanceAiChatsCollapsed"
|
||||
>
|
||||
<N8nText size="small" bold color="text-light">
|
||||
{{ locale.baseText('instanceAi.threads.chats') }}
|
||||
</N8nText>
|
||||
<N8nIcon
|
||||
icon="chevron-down"
|
||||
size="medium"
|
||||
:class="[$style.chevron, instanceAiChatsCollapsed ? $style.chevronCollapsed : '']"
|
||||
/>
|
||||
</button>
|
||||
<RouterLink :to="{ name: INSTANCE_AI_THREADS_VIEW }" :class="$style.instanceAiChatsViewAll">
|
||||
{{ locale.baseText('instanceAi.threads.viewAll') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<template v-if="!instanceAiChatsCollapsed">
|
||||
<div :class="$style.instanceAiChatItems">
|
||||
<N8nMenuItem
|
||||
v-for="thread in recentInstanceAiThreads"
|
||||
:key="thread.id"
|
||||
:item="getInstanceAiThreadMenuItem(thread)"
|
||||
scroll-label-on-overflow
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<AssistantChatsSection :collapsed="props.collapsed" />
|
||||
<template v-if="projectsStore.isTeamProjectFeatureEnabled && displayProjects.length > 0">
|
||||
<button
|
||||
v-if="!props.collapsed"
|
||||
|
|
@ -405,13 +323,6 @@ onBeforeUnmount(() => {
|
|||
padding: var(--spacing--2xs) var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.instanceAiSidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--4xs);
|
||||
padding: var(--spacing--3xs) var(--spacing--3xs) var(--spacing--xs);
|
||||
}
|
||||
|
||||
.instanceAiParentInactive {
|
||||
:global(.router-link-active) {
|
||||
background-color: transparent;
|
||||
|
|
@ -423,72 +334,6 @@ onBeforeUnmount(() => {
|
|||
}
|
||||
}
|
||||
|
||||
.instanceAiChatsHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-top: var(--spacing--4xs);
|
||||
border-radius: var(--spacing--4xs);
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color--background--light-1);
|
||||
color: var(--color--text--shade-1);
|
||||
|
||||
.chevron {
|
||||
color: var(--color--text--shade-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.instanceAiChatsToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--4xs);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: var(--spacing--4xs) var(--spacing--3xs);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--color--secondary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
.instanceAiChatsViewAll {
|
||||
flex-shrink: 0;
|
||||
padding: var(--spacing--4xs) var(--spacing--3xs);
|
||||
color: var(--text-color--subtler);
|
||||
font-size: var(--font-size--2xs);
|
||||
font-weight: var(--font-weight--regular);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: var(--text-color--subtle);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.instanceAiChatsViewAll {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.instanceAiChatsHeader:hover .instanceAiChatsViewAll,
|
||||
.instanceAiChatsHeader:has(.instanceAiChatsToggle:focus-visible) .instanceAiChatsViewAll,
|
||||
.instanceAiChatsViewAll:focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.upgradeLink {
|
||||
color: var(--color--primary);
|
||||
cursor: pointer;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user