From 3392cff11ed352acc68dd8e21dd799d5b7bb32e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:22:49 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_018gK4jZbBjL8toMAnrfJ4mh --- .../frontend/@n8n/i18n/src/locales/en.json | 5 + .../ai/instanceAi/InstanceAiThreadView.vue | 2 + .../__tests__/instanceAi.store.test.ts | 68 ++++ .../ai/instanceAi/instanceAi.store.ts | 3 + .../navigation/AssistantChatsSection.vue | 296 ++++++++++++++ .../__tests__/AssistantChatsSection.test.ts | 364 +++++++++++++++++ .../__tests__/threadDisplayState.test.ts | 208 ++++++++++ .../__tests__/useThreadLastViewed.test.ts | 375 ++++++++++++++++++ .../navigation/threadDisplayState.ts | 46 +++ .../navigation/useThreadLastViewed.ts | 126 ++++++ .../projects/components/ProjectNavigation.vue | 161 +------- 11 files changed, 1496 insertions(+), 158 deletions(-) create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/navigation/AssistantChatsSection.vue create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/navigation/__tests__/AssistantChatsSection.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/navigation/__tests__/threadDisplayState.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/navigation/__tests__/useThreadLastViewed.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/navigation/threadDisplayState.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/navigation/useThreadLastViewed.ts diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 8f0ac8ab29d..5c915ac8784 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue index eaf57a7ea83..6c04e5e0c5a 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue @@ -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. diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.store.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.store.test.ts index 003a9742884..7a58f590379 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.store.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.store.test.ts @@ -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(); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.store.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.store.ts index 5492fc35c5e..9b77c3be4eb 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.store.ts @@ -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, }; } diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/navigation/AssistantChatsSection.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/navigation/AssistantChatsSection.vue new file mode 100644 index 00000000000..10479e4eea5 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/navigation/AssistantChatsSection.vue @@ -0,0 +1,296 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/navigation/__tests__/AssistantChatsSection.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/navigation/__tests__/AssistantChatsSection.test.ts new file mode 100644 index 00000000000..87d86a602bb --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/navigation/__tests__/AssistantChatsSection.test.ts @@ -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(); +const renderComponent = createComponentRenderer(AssistantChatsSection); + +let router: Router; +let instanceAiStore: ReturnType>; + +function createTestRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'home', component: { template: '
' } }, + { path: '/assistant', name: INSTANCE_AI_VIEW, component: { template: '
' } }, + { + path: '/assistant/history', + name: INSTANCE_AI_THREADS_VIEW, + component: { template: '
' }, + }, + { + path: '/assistant/:threadId', + name: INSTANCE_AI_THREAD_VIEW, + component: { template: '
' }, + }, + ], + }); +} + +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 = {}) { + 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(); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/navigation/__tests__/threadDisplayState.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/navigation/__tests__/threadDisplayState.test.ts new file mode 100644 index 00000000000..df1f9dfb735 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/navigation/__tests__/threadDisplayState.test.ts @@ -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(['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('working', 'failed'), + fc.constantFrom(undefined, false), + timeArb, + timeArb, + (state, needsInput, lastActivityAt, lastViewedAt) => { + expect(threadDisplayState({ state, needsInput, lastActivityAt }, lastViewedAt)).toBe( + state, + ); + }, + ), + ); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/navigation/__tests__/useThreadLastViewed.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/navigation/__tests__/useThreadLastViewed.test.ts new file mode 100644 index 00000000000..260ba14a66b --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/navigation/__tests__/useThreadLastViewed.test.ts @@ -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(); + +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; + +/** 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; + 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; + 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(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/navigation/threadDisplayState.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/navigation/threadDisplayState.ts new file mode 100644 index 00000000000..ac7dd85e30b --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/navigation/threadDisplayState.ts @@ -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 | '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'; +} diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/navigation/useThreadLastViewed.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/navigation/useThreadLastViewed.ts new file mode 100644 index 00000000000..7fca36f7a55 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/navigation/useThreadLastViewed.ts @@ -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; + +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()); + +/** 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>) { + 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 }, + ); +} diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectNavigation.vue b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectNavigation.vue index 3b6a0677ea8..ca4dcc5d592 100644 --- a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectNavigation.vue +++ b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectNavigation.vue @@ -1,5 +1,4 @@