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:
Claude 2026-10-08 12:22:49 +00:00
parent a56279f513
commit 3392cff11e
No known key found for this signature in database
11 changed files with 1496 additions and 158 deletions

View File

@ -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",

View File

@ -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.

View File

@ -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();

View File

@ -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,
};
}

View File

@ -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>

View File

@ -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();
});
});
});

View File

@ -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,
);
},
),
);
});
});
});

View File

@ -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();
});
});

View File

@ -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';
}

View File

@ -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 },
);
}

View File

@ -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;