@n8n/frontend-module-inbox 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE_EE.md ADDED
@@ -0,0 +1,27 @@
1
+ # The n8n Enterprise License (the “Enterprise License”)
2
+
3
+ Copyright (c) 2022-present n8n GmbH.
4
+
5
+ With regard to the n8n Software:
6
+
7
+ This software and associated documentation files (the "Software") may only be used in production, if
8
+ you (and any entity that you represent) hold a valid n8n Enterprise license corresponding to your
9
+ usage. Subject to the foregoing sentence, you are free to modify this Software and publish patches
10
+ to the Software. You agree that n8n and/or its licensors (as applicable) retain all right, title and
11
+ interest in and to all such modifications and/or patches, and all such modifications and/or patches
12
+ may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid n8n
13
+ Enterprise license for the corresponding usage. Notwithstanding the foregoing, you may copy and
14
+ modify the Software for development and testing purposes, without requiring a subscription. You
15
+ agree that n8n and/or its licensors (as applicable) retain all right, title and interest in and to
16
+ all such modifications. You are not granted any other rights beyond what is expressly stated herein.
17
+ Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, and/or
18
+ sell the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
21
+ NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
22
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
23
+ OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
24
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
25
+
26
+ For all third party components incorporated into the n8n Software, those components are licensed
27
+ under the original license provided by the owner of the applicable component.
package/README.md CHANGED
@@ -1,14 +1,16 @@
1
1
  # @n8n/frontend-module-inbox
2
2
 
3
- The shared Inbox lists workflow reviews. Review details, comments, and decisions live in `src/reviews`. The editor supplies only the workflow diff renderer through the SDK component registry. Backend services keep their existing access rules and actions.
3
+ The shared Inbox lists workflow reviews and saved Assistant results. Review details, comments, and decisions live in `src/reviews`. The editor supplies only the workflow diff renderer through the SDK component registry. Backend services keep their existing access rules and actions.
4
4
 
5
- Open has two groups: Waiting for review and Authored by you. Each group has its
6
- own cursor, Load more button, and retry.
5
+ Open has two groups: Waiting for review and Authored by you. Assistant results go
6
+ in Waiting for review. Each group has its own cursor, Load more button, and retry.
7
7
  Closed is a flat list with infinite scroll. Group collapse state persists per user.
8
8
 
9
9
  Review decisions update the loaded rows and counts in place. Approving a selected
10
10
  review opens the Closed tab, as before.
11
11
 
12
+ Self-healing detail UI remains gated by the disabled-by-default self-healing rollout. AST-1518 replaces the explicit unavailable detail state.
13
+
12
14
  Frontend feature module. Consumed from source by the editor-ui shell through
13
15
  `src/app/modules.manifest.ts`; there is no build step and no `dist`.
14
16
 
package/package.json CHANGED
@@ -1,24 +1,25 @@
1
1
  {
2
2
  "name": "@n8n/frontend-module-inbox",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "exports": {
7
7
  ".": "./src/index.ts"
8
8
  },
9
9
  "dependencies": {
10
- "@n8n/api-types": "1.44.0",
11
- "@n8n/composables": "1.34.0",
12
- "@n8n/design-system": "2.43.0",
13
- "@n8n/frontend-module-sdk": "0.14.0",
14
- "@n8n/i18n": "2.44.0",
15
- "@n8n/rest-api-client": "2.44.0",
16
- "@n8n/stores": "2.44.0",
17
- "n8n-workflow": "2.44.0",
10
+ "@n8n/api-types": "1.45.0",
11
+ "@n8n/composables": "1.35.0",
12
+ "@n8n/design-system": "2.44.0",
13
+ "@n8n/frontend-module-sdk": "0.15.0",
14
+ "@n8n/i18n": "2.45.0",
15
+ "@n8n/rest-api-client": "2.45.0",
16
+ "@n8n/stores": "2.45.0",
17
+ "n8n-workflow": "2.45.0",
18
18
  "pinia": "^2.2.4",
19
+ "reka-ui": "^2.5.0",
19
20
  "vue": "^3.5.13",
20
21
  "vue-router": "^4.5.0",
21
- "@n8n/frontend-constants": "0.10.0",
22
+ "@n8n/frontend-constants": "0.11.0",
22
23
  "@vueuse/core": "^14.3.0",
23
24
  "lodash": "4.18.1",
24
25
  "vue-i18n": "^11.4.4"
@@ -46,9 +47,18 @@
46
47
  "@pinia/testing": "^0.1.6",
47
48
  "@testing-library/user-event": "^14.6.1",
48
49
  "@types/lodash": "4.17.17",
49
- "@n8n/utils": "1.51.0"
50
+ "@n8n/utils": "1.52.0"
51
+ },
52
+ "license": "SEE LICENSE IN LICENSE.md",
53
+ "homepage": "https://n8n.io",
54
+ "author": {
55
+ "name": "Jan Oberhauser",
56
+ "email": "jan@n8n.io"
57
+ },
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "git+https://github.com/n8n-io/n8n.git"
50
61
  },
51
- "license": "LicenseRef-n8n-sustainable-use",
52
62
  "scripts": {
53
63
  "clean": "rimraf .turbo",
54
64
  "typecheck": "vue-tsc --noEmit",
@@ -21,7 +21,7 @@ const i18n = useI18n();
21
21
  const alertIcon = { type: 'icon', value: 'circle-alert' } as const;
22
22
  const inboxIcon: EmptyStateIconCards = {
23
23
  type: 'cards',
24
- center: 'message-square-text',
24
+ center: 'inbox',
25
25
  sides: ['file-diff', 'git-branch', 'circle-check', 'list', 'message-square'],
26
26
  };
27
27
  const noSelectionHeading = computed(() => {
@@ -1,6 +1,7 @@
1
- import type { InboxWorkflowReviewItem } from '@n8n/api-types';
1
+ import type { InboxSelfHealingItem, InboxWorkflowReviewItem } from '@n8n/api-types';
2
2
  import { createComponentRenderer } from '@n8n/frontend-test-utils';
3
3
  import { useUsersStore } from '@n8n/stores/users.store';
4
+ import userEvent from '@testing-library/user-event';
4
5
  import { fireEvent, within } from '@testing-library/vue';
5
6
  import { useIntersectionObserver } from '@vueuse/core';
6
7
 
@@ -28,6 +29,22 @@ function section(
28
29
  ...props,
29
30
  };
30
31
  }
32
+ function result(props: Partial<InboxSelfHealingItem> = {}): InboxSelfHealingItem {
33
+ return {
34
+ type: 'self_healing_result',
35
+ id: 'result',
36
+ state: 'open',
37
+ projectId: 'project',
38
+ workflowId: 'workflow',
39
+ workflowName: 'My workflow',
40
+ summary: 'Fix the workflow',
41
+ outcome: 'fix_ready',
42
+ createdAt: '2026-01-01T00:00:00.000Z',
43
+ updatedAt: '2026-01-01T00:00:00.000Z',
44
+ completedAt: '2026-01-01T00:00:00.000Z',
45
+ ...props,
46
+ };
47
+ }
31
48
  function review(): InboxWorkflowReviewItem {
32
49
  return {
33
50
  type: 'workflow_review',
@@ -106,12 +123,12 @@ it('keeps healthy rows visible beside a failed group', () => {
106
123
  const { queryByRole, getByRole, emitted } = renderComponent({
107
124
  props: {
108
125
  sections: [
109
- section('waiting', { items: [review()] }),
126
+ section('waiting', { items: [result()] }),
110
127
  section('authored', { error: new Error('Request failed') }),
111
128
  ],
112
129
  },
113
130
  });
114
- expect(getByRole('option')).toHaveTextContent('Review my workflow');
131
+ expect(getByRole('button', { name: /Fix the workflow/ })).toHaveTextContent('Fix the workflow');
115
132
  getByRole('button', { name: 'Retry' }).click();
116
133
  expect(emitted('retry')).toEqual([['authored']]);
117
134
  expect(queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
@@ -130,14 +147,14 @@ it('keeps a partial empty group visible with its own retry', () => {
130
147
  });
131
148
  it('collapses groups without requesting more data', async () => {
132
149
  const { getByRole, queryByRole, emitted } = renderComponent({
133
- props: { sections: [section('waiting', { items: [review()] })] },
150
+ props: { sections: [section('waiting', { items: [result()] })] },
134
151
  });
135
152
  const header = getByRole('button', { name: 'Waiting for your review' });
136
153
  await fireEvent.click(header);
137
154
  expect(header).toHaveAttribute('aria-expanded', 'false');
138
- expect(queryByRole('option')).not.toBeInTheDocument();
155
+ expect(queryByRole('button', { name: /Fix the workflow/ })).not.toBeInTheDocument();
139
156
  await fireEvent.click(header);
140
- expect(getByRole('option')).toHaveTextContent('Review my workflow');
157
+ expect(getByRole('button', { name: /Fix the workflow/ })).toHaveTextContent('Fix the workflow');
141
158
  expect(emitted('loadMore')).toBeUndefined();
142
159
  });
143
160
  it('uses the impersonal waiting label for administrators', () => {
@@ -202,6 +219,34 @@ it('waits for Retry after a failed Closed page and removes the sentinel on Open'
202
219
  await rerender({ activeTab: 'open', sections: [section('waiting')] });
203
220
  expect(target).toHaveProperty('value', null);
204
221
  });
222
+ it('renders compact rows with an Assistant avatar and no source subtitle', () => {
223
+ const { getByRole, queryByText } = renderComponent({
224
+ props: { sections: [section('waiting', { items: [result(), review()] })] },
225
+ });
226
+ const [assistantRow, reviewRow] = within(getByRole('list')).getAllByRole('button');
227
+ expect(within(assistantRow).getByTestId('inbox-assistant-avatar')).toBeInTheDocument();
228
+ expect(within(reviewRow).queryByTestId('inbox-assistant-avatar')).not.toBeInTheDocument();
229
+ expect(within(assistantRow).getByText('My workflow')).toBeInTheDocument();
230
+ expect(queryByText('Workflow review')).not.toBeInTheDocument();
231
+ expect(queryByText('Fix ready')).not.toBeInTheDocument();
232
+ });
233
+ it.each([
234
+ ['fix_ready', 'open', 'Open | Fix ready', 'fixReady'],
235
+ ['needs_you', 'open', 'Open | Needs attention', 'needsAttention'],
236
+ ['could_not_fix', 'open', 'Open | Could not fix', 'needsAttention'],
237
+ ['fix_ready', 'closed', 'Closed | Fix ready', 'closed'],
238
+ ] as const)(
239
+ 'labels Assistant %s/%s without implying review approval',
240
+ (outcome, state, label, color) => {
241
+ const { getByRole } = renderComponent({
242
+ props: { sections: [section('waiting', { items: [result({ outcome, state })] })] },
243
+ });
244
+ const dot = getByRole('img', { name: label });
245
+ expect(dot.className).toContain(color);
246
+ expect(dot).not.toHaveAccessibleName(/approved/i);
247
+ },
248
+ );
249
+
205
250
  it('separates partial-source recovery from a failed page retry', () => {
206
251
  const { getByTestId, emitted } = renderComponent({
207
252
  props: { sections: [section('waiting', { partial: true, error: new Error('Page failed') })] },
@@ -213,17 +258,32 @@ it('separates partial-source recovery from a failed page retry', () => {
213
258
  expect(emitted('retry')).toEqual([['waiting']]);
214
259
  });
215
260
 
216
- it('keeps review selection and keyboard activation across the extracted row', async () => {
217
- const item = review();
218
- const { getByRole, emitted } = renderComponent({
261
+ it('uses Tab, Enter, and Space to select rows in an ordinary list', async () => {
262
+ const user = userEvent.setup();
263
+ const first = review();
264
+ const second = result();
265
+ const { getByRole, queryByRole, emitted, rerender } = renderComponent({
219
266
  props: {
220
- selectedKey: `${item.type}:${item.id}`,
221
- sections: [section('waiting', { items: [item] })],
267
+ selectedKey: `${first.type}:${first.id}`,
268
+ sections: [section('waiting', { items: [first, second] })],
222
269
  },
223
270
  });
224
- const row = getByRole('option', { selected: true });
225
- expect(row).toHaveTextContent(item.title);
226
- await fireEvent.keyDown(row, { key: 'Enter' });
227
- await fireEvent.keyDown(row, { key: ' ' });
228
- expect(emitted('select')).toEqual([[item], [item]]);
271
+ const list = getByRole('list', { name: 'Waiting for your review' });
272
+ const rows = within(list)
273
+ .getAllByRole('listitem')
274
+ .map((item) => within(item).getByRole('button'));
275
+ expect(queryByRole('listbox')).not.toBeInTheDocument();
276
+ expect(rows[0]).toHaveAttribute('aria-current', 'true');
277
+ expect(rows[1]).not.toHaveAttribute('aria-current');
278
+ getByRole('button', { name: 'Waiting for your review' }).focus();
279
+ await user.tab();
280
+ expect(rows[0]).toHaveFocus();
281
+ await user.keyboard('{Enter}');
282
+ await user.tab();
283
+ expect(rows[1]).toHaveFocus();
284
+ await user.keyboard(' ');
285
+ expect(emitted('select')).toEqual([[first], [second]]);
286
+ await rerender({ selectedKey: `${second.type}:${second.id}` });
287
+ expect(rows[0]).not.toHaveAttribute('aria-current');
288
+ expect(rows[1]).toHaveAttribute('aria-current', 'true');
229
289
  });
@@ -1,21 +1,41 @@
1
1
  <script setup lang="ts">
2
- import type { InboxItem } from '@n8n/api-types';
3
- import { N8nBadge, N8nCard, N8nIcon, N8nText, N8nTimeAgo } from '@n8n/design-system';
2
+ import type { InboxItem, InboxSelfHealingItem } from '@n8n/api-types';
3
+ import {
4
+ N8nAssistantAvatar,
5
+ N8nBadge,
6
+ N8nCard,
7
+ N8nIcon,
8
+ N8nText,
9
+ N8nTimeAgo,
10
+ } from '@n8n/design-system';
11
+ import { useI18n } from '@n8n/i18n';
4
12
  import { useRootStore } from '@n8n/stores/useRootStore';
5
13
 
6
14
  import WorkflowReviewStatusDot from '../reviews/components/WorkflowReviewStatusDot.vue';
7
15
 
8
16
  defineProps<{ item: InboxItem; selected: boolean }>();
9
17
  const emit = defineEmits<{ select: [] }>();
18
+ const i18n = useI18n();
10
19
  const rootStore = useRootStore();
20
+
21
+ function outcomeLabel(item: InboxSelfHealingItem) {
22
+ switch (item.outcome) {
23
+ case 'fix_ready':
24
+ return i18n.baseText('inbox.outcome.fixReady');
25
+ case 'needs_you':
26
+ return i18n.baseText('inbox.outcome.needsAttention');
27
+ case 'could_not_fix':
28
+ return i18n.baseText('inbox.outcome.couldNotFix');
29
+ }
30
+ }
11
31
  </script>
12
32
 
13
33
  <template>
14
34
  <N8nCard
15
35
  :class="[$style.card, { [$style.selected]: selected }]"
16
- role="option"
36
+ role="button"
17
37
  tabindex="0"
18
- :aria-selected="selected"
38
+ :aria-current="selected ? 'true' : undefined"
19
39
  :data-source="item.type"
20
40
  data-test-id="inbox-row"
21
41
  @click="emit('select')"
@@ -24,8 +44,40 @@ const rootStore = useRootStore();
24
44
  >
25
45
  <div :class="$style.cardContent">
26
46
  <div :class="$style.cardHeader">
27
- <N8nText bold tag="h3" :class="$style.cardTitle">{{ item.title }}</N8nText>
28
- <WorkflowReviewStatusDot :state="item.state" :decision="item.decision" />
47
+ <N8nAssistantAvatar
48
+ v-if="item.type === 'self_healing_result'"
49
+ size="mini"
50
+ :class="$style.assistantAvatar"
51
+ data-test-id="inbox-assistant-avatar"
52
+ />
53
+ <N8nText
54
+ bold
55
+ tag="h3"
56
+ :class="[
57
+ $style.cardTitle,
58
+ { [$style.assistantTitle]: item.type === 'self_healing_result' },
59
+ ]"
60
+ >{{ item.type === 'workflow_review' ? item.title : item.summary }}</N8nText
61
+ >
62
+ <WorkflowReviewStatusDot
63
+ v-if="item.type === 'workflow_review'"
64
+ :state="item.state"
65
+ :decision="item.decision"
66
+ />
67
+ <span
68
+ v-else
69
+ :class="[
70
+ $style.statusDot,
71
+ item.state === 'closed'
72
+ ? $style.closed
73
+ : item.outcome === 'fix_ready'
74
+ ? $style.fixReady
75
+ : $style.needsAttention,
76
+ ]"
77
+ role="img"
78
+ :aria-label="`${i18n.baseText(item.state === 'open' ? 'inbox.tabs.open' : 'inbox.tabs.closed')} | ${outcomeLabel(item)}`"
79
+ data-test-id="inbox-assistant-status"
80
+ />
29
81
  </div>
30
82
  <div :class="$style.meta">
31
83
  <N8nBadge v-if="item.workflowName" variant="outline" :class="$style.workflowBadge">
@@ -35,7 +87,7 @@ const rootStore = useRootStore();
35
87
  </N8nBadge>
36
88
  <div :class="$style.metaActions">
37
89
  <N8nText :class="$style.time" size="xsmall" color="text-light">
38
- <N8nTimeAgo :date="item.createdAt" :locale="rootStore.defaultLocale" />
90
+ <N8nTimeAgo :date="item.updatedAt" :locale="rootStore.defaultLocale" />
39
91
  </N8nText>
40
92
  </div>
41
93
  </div>
@@ -87,6 +139,13 @@ const rootStore = useRootStore();
87
139
  font-size: var(--font-size--sm);
88
140
  }
89
141
 
142
+ .assistantTitle {
143
+ flex: 1;
144
+ }
145
+
146
+ .assistantAvatar {
147
+ flex-shrink: 0;
148
+ }
90
149
  .meta {
91
150
  display: flex;
92
151
  align-items: center;
@@ -131,4 +190,19 @@ const rootStore = useRootStore();
131
190
  .time {
132
191
  white-space: nowrap;
133
192
  }
193
+ .statusDot {
194
+ flex-shrink: 0;
195
+ width: var(--font-size--3xs);
196
+ height: var(--font-size--3xs);
197
+ border-radius: 50%;
198
+ }
199
+ .fixReady {
200
+ background-color: var(--color--blue-500);
201
+ }
202
+ .needsAttention {
203
+ background-color: var(--color--yellow-500);
204
+ }
205
+ .closed {
206
+ background-color: var(--color--neutral-500);
207
+ }
134
208
  </style>
@@ -63,20 +63,20 @@ function sectionTitle(key: 'waiting' | 'authored') {
63
63
  <div
64
64
  :id="`inbox-section-${section.key}`"
65
65
  :class="$style.group"
66
- role="listbox"
66
+ role="list"
67
67
  :aria-labelledby="
68
68
  section.key !== 'closed' ? `inbox-section-header-${section.key}` : undefined
69
69
  "
70
70
  :aria-label="section.key === 'closed' ? i18n.baseText('inbox.tabs.closed') : undefined"
71
71
  >
72
72
  <template v-if="!collapsed">
73
- <InboxListItem
74
- v-for="item in section.items"
75
- :key="`${item.type}:${item.id}`"
76
- :item="item"
77
- :selected="selectedKey === `${item.type}:${item.id}`"
78
- @select="emit('select', item)"
79
- />
73
+ <div v-for="item in section.items" :key="`${item.type}:${item.id}`" role="listitem">
74
+ <InboxListItem
75
+ :item="item"
76
+ :selected="selectedKey === `${item.type}:${item.id}`"
77
+ @select="emit('select', item)"
78
+ />
79
+ </div>
80
80
  </template>
81
81
  </div>
82
82
  <template v-if="!collapsed">
@@ -1,11 +1,14 @@
1
1
  import type { InboxSourceType, InboxState, WorkflowReviewRequestDecision } from '@n8n/api-types';
2
2
 
3
3
  export const INBOX_VIEW = 'Inbox';
4
+ export const INBOX_ASSISTANT_RESULT_VIEW = 'InboxAssistantResult';
4
5
  export const INBOX_PAGE_LIMIT = 15;
5
6
 
6
7
  export type InboxSectionKey = 'waiting' | 'authored' | 'closed';
7
8
 
8
- export type InboxSelection = { type: 'workflow_review'; id: string };
9
+ export type InboxSelection =
10
+ | { type: 'workflow_review'; id: string }
11
+ | { type: 'self_healing_result'; id: string; projectId: string; workflowId: string };
9
12
 
10
13
  export type InboxItemChange = {
11
14
  type: InboxSourceType;
@@ -16,7 +16,7 @@ const inboxView = {
16
16
  export const InboxModule = defineFrontendModule({
17
17
  id: 'inbox',
18
18
  name: 'Inbox',
19
- description: 'The shared Inbox for workflow reviews.',
19
+ description: 'Workflow reviews and saved Assistant results.',
20
20
  icon: 'inbox',
21
21
  routes: [
22
22
  // Keep legacy /reviews links working after the move to Inbox.
@@ -46,5 +46,10 @@ export const InboxModule = defineFrontendModule({
46
46
  path: '/inbox/reviews/:reviewId',
47
47
  name: 'WorkflowReviewRequestsView',
48
48
  },
49
+ {
50
+ ...inboxView,
51
+ path: '/inbox/assistant-results/:resultId',
52
+ name: 'InboxAssistantResult',
53
+ },
49
54
  ],
50
55
  });
@@ -1,4 +1,3 @@
1
- import type { InboxWorkflowReviewItem } from '@n8n/api-types';
2
1
  import { createMemoryHistory, createRouter } from 'vue-router';
3
2
 
4
3
  import { InboxModule } from './inbox.module';
@@ -18,21 +17,19 @@ function createInboxRouter() {
18
17
  return createRouter({ history: createMemoryHistory(), routes });
19
18
  }
20
19
 
21
- const review = {
22
- type: 'workflow_review',
23
- id: 'review-1',
20
+ const result = {
21
+ type: 'self_healing_result',
22
+ id: 'result-1',
24
23
  state: 'open',
25
24
  projectId: 'project',
26
- title: 'Review',
25
+ workflowId: 'workflow',
27
26
  workflowName: 'Workflow',
28
- workflowVersionId: null,
29
- requester: null,
30
- authors: [],
31
- reviewers: [],
32
- decision: 'pending',
27
+ summary: 'Summary',
28
+ outcome: 'fix_ready',
33
29
  createdAt: '',
34
30
  updatedAt: '',
35
- } satisfies InboxWorkflowReviewItem;
31
+ completedAt: '',
32
+ } as const;
36
33
 
37
34
  it('redirects to home when Inbox is disabled', async () => {
38
35
  settingsStore.settings.inbox.enabled = false;
@@ -60,18 +57,36 @@ it('redirects the old review list without selecting an item', async () => {
60
57
  expect(selectionFromRoute(router.currentRoute.value)).toBeNull();
61
58
  });
62
59
 
63
- it('selects a review when opening its detail route directly', async () => {
60
+ it.each([
61
+ ['/inbox/reviews/review-1', { type: 'workflow_review', id: 'review-1' }],
62
+ [
63
+ '/inbox/assistant-results/result-1?projectId=project&workflowId=workflow',
64
+ {
65
+ type: result.type,
66
+ id: result.id,
67
+ projectId: result.projectId,
68
+ workflowId: result.workflowId,
69
+ },
70
+ ],
71
+ ])('selects the item when opening %s directly', async (path, selection) => {
64
72
  const router = createInboxRouter();
65
- await router.push('/inbox/reviews/review-1');
66
- expect(selectionFromRoute(router.currentRoute.value)).toEqual({
67
- type: 'workflow_review',
68
- id: 'review-1',
69
- });
73
+ await router.push(path);
74
+ expect(selectionFromRoute(router.currentRoute.value)).toEqual(selection);
70
75
  });
71
76
 
77
+ it.each(['', '?projectId=project', '?workflowId=workflow'])(
78
+ 'requires Assistant scope: %s',
79
+ async (query) => {
80
+ const router = createInboxRouter();
81
+ await router.push(`/inbox/assistant-results/result-1${query}`);
82
+ expect(selectionFromRoute(router.currentRoute.value)).toBeNull();
83
+ },
84
+ );
85
+
72
86
  it.each([
73
87
  ['Inbox', true],
74
88
  ['WorkflowReviewRequestsView', true],
89
+ ['InboxAssistantResult', true],
75
90
  ['Workflow', false],
76
91
  [undefined, false],
77
92
  ])('recognizes the Inbox route %s: %s', (name, expected) => {
@@ -80,22 +95,34 @@ it.each([
80
95
 
81
96
  it('keeps the tab only when selecting the same item', async () => {
82
97
  const router = createInboxRouter();
83
- await router.push('/inbox/reviews/review-1?state=closed&tab=changes');
84
- expect(router.resolve(inboxItemLocation(review, router.currentRoute.value))).toMatchObject({
85
- path: '/inbox/reviews/review-1',
86
- query: { state: 'closed', tab: 'changes' },
98
+ await router.push(
99
+ '/inbox/assistant-results/result-1?projectId=project&workflowId=workflow&state=closed&tab=changes',
100
+ );
101
+ expect(router.resolve(inboxItemLocation(result, router.currentRoute.value))).toMatchObject({
102
+ path: '/inbox/assistant-results/result-1',
103
+ query: { projectId: 'project', workflowId: 'workflow', state: 'closed', tab: 'changes' },
87
104
  });
88
105
  expect(
89
- router.resolve(inboxItemLocation({ ...review, id: 'review-2' }, router.currentRoute.value))
106
+ router.resolve(inboxItemLocation({ ...result, id: 'result-2' }, router.currentRoute.value))
90
107
  .query,
91
- ).toEqual({ state: 'closed' });
108
+ ).toEqual({
109
+ projectId: 'project',
110
+ workflowId: 'workflow',
111
+ state: 'closed',
112
+ });
113
+ await router.push('/inbox/reviews/result-1?state=closed&tab=changes');
114
+ expect(router.resolve(inboxItemLocation(result, router.currentRoute.value)).query).toEqual({
115
+ projectId: 'project',
116
+ workflowId: 'workflow',
117
+ state: 'closed',
118
+ });
92
119
  });
93
120
 
94
121
  it('restores selection and tabs when navigating back and forward', async () => {
95
122
  const router = createInboxRouter();
96
123
  await router.push('/inbox');
97
124
  await router.push('/inbox/reviews/review-1?tab=changes');
98
- await router.push(inboxItemLocation({ ...review, id: 'review-2' }, router.currentRoute.value));
125
+ await router.push(inboxItemLocation(result, router.currentRoute.value));
99
126
  router.back();
100
127
  await vi.waitFor(() =>
101
128
  expect(router.currentRoute.value.fullPath).toBe('/inbox/reviews/review-1?tab=changes'),
@@ -105,7 +132,9 @@ it('restores selection and tabs when navigating back and forward', async () => {
105
132
  id: 'review-1',
106
133
  });
107
134
  router.forward();
108
- await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/inbox/reviews/review-2'));
109
- expect(selectionFromRoute(router.currentRoute.value)?.id).toBe('review-2');
135
+ await vi.waitFor(() =>
136
+ expect(router.currentRoute.value.path).toBe('/inbox/assistant-results/result-1'),
137
+ );
138
+ expect(selectionFromRoute(router.currentRoute.value)?.id).toBe(result.id);
110
139
  expect(router.currentRoute.value.query.tab).toBeUndefined();
111
140
  });
@@ -1,7 +1,7 @@
1
1
  import type { InboxItem } from '@n8n/api-types';
2
2
  import type { LocationQuery, RouteLocationNormalizedLoaded, RouteLocationRaw } from 'vue-router';
3
3
 
4
- import { INBOX_VIEW, type InboxSelection } from './inbox.constants';
4
+ import { INBOX_ASSISTANT_RESULT_VIEW, INBOX_VIEW, type InboxSelection } from './inbox.constants';
5
5
  import { WORKFLOW_REVIEW_REQUESTS_VIEW } from './reviews/constants';
6
6
 
7
7
  type InboxRoute = Pick<RouteLocationNormalizedLoaded, 'name' | 'params' | 'query'>;
@@ -11,14 +11,26 @@ function stringParam(value: LocationQuery[string]): string | undefined {
11
11
  }
12
12
 
13
13
  export function isInboxRoute(route: Pick<InboxRoute, 'name'>): boolean {
14
- return route.name === INBOX_VIEW || route.name === WORKFLOW_REVIEW_REQUESTS_VIEW;
14
+ return (
15
+ route.name === INBOX_VIEW ||
16
+ route.name === WORKFLOW_REVIEW_REQUESTS_VIEW ||
17
+ route.name === INBOX_ASSISTANT_RESULT_VIEW
18
+ );
15
19
  }
16
20
 
17
- export function selectionFromRoute({ name, params }: InboxRoute): InboxSelection | null {
21
+ export function selectionFromRoute({ name, params, query }: InboxRoute): InboxSelection | null {
18
22
  if (name === WORKFLOW_REVIEW_REQUESTS_VIEW) {
19
23
  const id = stringParam(params.reviewId);
20
24
  return id ? { type: 'workflow_review', id } : null;
21
25
  }
26
+ if (name !== INBOX_ASSISTANT_RESULT_VIEW) return null;
27
+ const id = stringParam(params.resultId);
28
+ if (!id) return null;
29
+ const projectId = stringParam(query.projectId);
30
+ const workflowId = stringParam(query.workflowId);
31
+ if (projectId && workflowId) {
32
+ return { type: 'self_healing_result', id, projectId, workflowId };
33
+ }
22
34
  return null;
23
35
  }
24
36
 
@@ -26,5 +38,12 @@ export function inboxItemLocation(item: InboxItem, route?: InboxRoute): RouteLoc
26
38
  const next: LocationQuery = { ...route?.query };
27
39
  const selected = route ? selectionFromRoute(route) : null;
28
40
  if (selected?.type !== item.type || selected.id !== item.id) delete next.tab;
41
+ delete next.projectId;
42
+ delete next.workflowId;
43
+ if (item.type === 'self_healing_result') {
44
+ next.projectId = item.projectId;
45
+ next.workflowId = item.workflowId;
46
+ return { name: INBOX_ASSISTANT_RESULT_VIEW, params: { resultId: item.id }, query: next };
47
+ }
29
48
  return { name: WORKFLOW_REVIEW_REQUESTS_VIEW, params: { reviewId: item.id }, query: next };
30
49
  }