@n8n/frontend-module-inbox 0.0.0-stage → 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.
Files changed (62) hide show
  1. package/LICENSE.md +88 -0
  2. package/LICENSE_EE.md +27 -0
  3. package/README.md +61 -2
  4. package/biome.jsonc +4 -0
  5. package/eslint.config.mjs +4 -0
  6. package/package.json +72 -3
  7. package/src/__tests__/design-system-icons.test.ts +24 -0
  8. package/src/__tests__/setup.ts +4 -0
  9. package/src/components/InboxEmptyState.vue +86 -0
  10. package/src/components/InboxList.test.ts +289 -0
  11. package/src/components/InboxList.vue +195 -0
  12. package/src/components/InboxListItem.vue +208 -0
  13. package/src/components/InboxListSection.vue +168 -0
  14. package/src/composables/useReviewInboxSectionCollapse.test.ts +66 -0
  15. package/src/composables/useReviewInboxSectionCollapse.ts +42 -0
  16. package/src/inbox.api.ts +20 -0
  17. package/src/inbox.constants.ts +19 -0
  18. package/src/inbox.module.ts +55 -0
  19. package/src/inbox.routes.test.ts +140 -0
  20. package/src/inbox.routes.ts +49 -0
  21. package/src/inbox.store.test.ts +444 -0
  22. package/src/inbox.store.ts +305 -0
  23. package/src/index.ts +5 -0
  24. package/src/reviews/WorkflowReviewDetail.test.ts +647 -0
  25. package/src/reviews/WorkflowReviewDetail.vue +257 -0
  26. package/src/reviews/components/WorkflowReviewActivityFeed.test.ts +923 -0
  27. package/src/reviews/components/WorkflowReviewActivityFeed.vue +312 -0
  28. package/src/reviews/components/WorkflowReviewChangesList.test.ts +97 -0
  29. package/src/reviews/components/WorkflowReviewChangesList.vue +143 -0
  30. package/src/reviews/components/WorkflowReviewChangesSection.test.ts +331 -0
  31. package/src/reviews/components/WorkflowReviewChangesSection.vue +217 -0
  32. package/src/reviews/components/WorkflowReviewCommentComposer.test.ts +175 -0
  33. package/src/reviews/components/WorkflowReviewCommentComposer.vue +83 -0
  34. package/src/reviews/components/WorkflowReviewDecisionPopover.test.ts +288 -0
  35. package/src/reviews/components/WorkflowReviewDecisionPopover.vue +203 -0
  36. package/src/reviews/components/WorkflowReviewDetailMetadata.test.ts +206 -0
  37. package/src/reviews/components/WorkflowReviewDetailMetadata.vue +245 -0
  38. package/src/reviews/components/WorkflowReviewDetailTabs.decisionNote.test.ts +90 -0
  39. package/src/reviews/components/WorkflowReviewDetailTabs.test.ts +500 -0
  40. package/src/reviews/components/WorkflowReviewDetailTabs.vue +329 -0
  41. package/src/reviews/components/WorkflowReviewStatusDot.vue +67 -0
  42. package/src/reviews/components/_activity-card.scss +36 -0
  43. package/src/reviews/components/activity-entries/WorkflowReviewActivityActorAvatar.vue +50 -0
  44. package/src/reviews/components/activity-entries/WorkflowReviewActivityComment.test.ts +103 -0
  45. package/src/reviews/components/activity-entries/WorkflowReviewActivityComment.vue +116 -0
  46. package/src/reviews/components/activity-entries/WorkflowReviewActivityEventEntry.vue +361 -0
  47. package/src/reviews/components/activity-entries/WorkflowReviewActivityFallback.vue +49 -0
  48. package/src/reviews/components/activity-entries/WorkflowReviewActivityWorkflowLink.vue +36 -0
  49. package/src/reviews/components/activityEntryRegistry.ts +27 -0
  50. package/src/reviews/constants.ts +41 -0
  51. package/src/reviews/reviewActivity.store.test.ts +463 -0
  52. package/src/reviews/reviewActivity.store.ts +186 -0
  53. package/src/reviews/reviewDetail.store.test.ts +135 -0
  54. package/src/reviews/reviewDetail.store.ts +74 -0
  55. package/src/reviews/workflowReviewStatus.utils.ts +44 -0
  56. package/src/reviews/workflowReviews.api.ts +61 -0
  57. package/src/reviews/workflowReviews.utils.ts +18 -0
  58. package/src/self-healing/SelfHealingResultDetail.vue +19 -0
  59. package/src/views/InboxView.test.ts +568 -0
  60. package/src/views/InboxView.vue +260 -0
  61. package/stylelint.config.mjs +9 -0
  62. package/vite.config.ts +52 -0
@@ -0,0 +1,361 @@
1
+ <script setup lang="ts">
2
+ import type { WorkflowReviewActivityEntry, WorkflowReviewClosedReason } from '@n8n/api-types';
3
+ import { N8nCallout, N8nIcon, N8nText, N8nTimeAgo } from '@n8n/design-system';
4
+ import { type BaseTextKey, useI18n } from '@n8n/i18n';
5
+ import { computed, inject } from 'vue';
6
+ import { I18nT } from 'vue-i18n';
7
+
8
+ import { useRootStore } from '@n8n/stores/useRootStore';
9
+ import { getVersionLabel } from '@n8n/composables/useWorkflowVersionLabel';
10
+
11
+ import { ReviewLinkedWorkflowsKey, type ReviewLinkedWorkflowContext } from '../../constants';
12
+ import { formatActorName } from '../../workflowReviews.utils';
13
+ import WorkflowReviewActivityActorAvatar from './WorkflowReviewActivityActorAvatar.vue';
14
+ import WorkflowReviewActivityFallback from './WorkflowReviewActivityFallback.vue';
15
+ import WorkflowReviewActivityWorkflowLink from './WorkflowReviewActivityWorkflowLink.vue';
16
+
17
+ const props = defineProps<{
18
+ entry: Extract<
19
+ WorkflowReviewActivityEntry,
20
+ {
21
+ type:
22
+ | 'review.opened'
23
+ | 'review.changes_requested'
24
+ | 'review.approved'
25
+ | 'review.version_updated'
26
+ | 'review.closed'
27
+ | 'workflow.archived'
28
+ | 'workflow.deleted'
29
+ | 'workflow.moved'
30
+ | 'workflow.published';
31
+ }
32
+ >;
33
+ }>();
34
+
35
+ const i18n = useI18n();
36
+ const rootStore = useRootStore();
37
+
38
+ const linkedWorkflows = inject(
39
+ ReviewLinkedWorkflowsKey,
40
+ computed(() => new Map<string, ReviewLinkedWorkflowContext>()),
41
+ );
42
+
43
+ const closedReasonKeys: Record<WorkflowReviewClosedReason, BaseTextKey> = {
44
+ 'no-reviewable-workflows': 'workflowReviews.detail.closedCallout.noReviewableWorkflows',
45
+ };
46
+
47
+ /**
48
+ * A lifecycle close concludes the feed, so it renders as a callout rather than one more
49
+ * sentence. The body is a keypath, and the stored reason, so there is nothing to fall back to.
50
+ */
51
+ const closedCallout = computed<{ title: string; bodyKey: BaseTextKey } | null>(() => {
52
+ const entry = props.entry;
53
+ if (entry.type !== 'review.closed' || !entry.data) return null;
54
+ return {
55
+ title: i18n.baseText('workflowReviews.detail.closedCallout.title'),
56
+ bodyKey: closedReasonKeys[entry.data.reason],
57
+ };
58
+ });
59
+
60
+ // A cause event's sentence names the actor when a user acted, or says the system did it.
61
+ const causeKeys: Record<
62
+ 'workflow.archived' | 'workflow.deleted' | 'workflow.moved',
63
+ {
64
+ user: BaseTextKey;
65
+ userNamed: BaseTextKey;
66
+ system: BaseTextKey;
67
+ systemNamed: BaseTextKey;
68
+ testId: string;
69
+ }
70
+ > = {
71
+ 'workflow.archived': {
72
+ user: 'workflowReviews.detail.activity.workflowArchived.user',
73
+ userNamed: 'workflowReviews.detail.activity.workflowArchived.user.named',
74
+ system: 'workflowReviews.detail.activity.workflowArchived.system',
75
+ systemNamed: 'workflowReviews.detail.activity.workflowArchived.system.named',
76
+ testId: 'workflow-review-activity-workflow-archived',
77
+ },
78
+ 'workflow.deleted': {
79
+ user: 'workflowReviews.detail.activity.workflowDeleted.user',
80
+ userNamed: 'workflowReviews.detail.activity.workflowDeleted.user.named',
81
+ system: 'workflowReviews.detail.activity.workflowDeleted.system',
82
+ systemNamed: 'workflowReviews.detail.activity.workflowDeleted.system.named',
83
+ testId: 'workflow-review-activity-workflow-deleted',
84
+ },
85
+ 'workflow.moved': {
86
+ user: 'workflowReviews.detail.activity.workflowMoved.user',
87
+ userNamed: 'workflowReviews.detail.activity.workflowMoved.user.named',
88
+ system: 'workflowReviews.detail.activity.workflowMoved.system',
89
+ systemNamed: 'workflowReviews.detail.activity.workflowMoved.system.named',
90
+ testId: 'workflow-review-activity-workflow-moved',
91
+ },
92
+ };
93
+
94
+ /**
95
+ * The sentence for one entry: an i18n key plus its placeholder values, one case per type.
96
+ * `null` means "no sentence" — `review.closed` renders as a callout instead, and a
97
+ * `workflow.published` entry with an unreadable payload has nothing to say. Other types
98
+ * survive a bad payload with a reduced sentence: a decision loses its note, a cause event
99
+ * its actor.
100
+ *
101
+ * `workflow` is set only when the workflow still exists.
102
+ *
103
+ * `namesActor` comes from the entry itself, never from `createdBy` — that is also null for
104
+ * deleted users, whose actions must still read as a person's.
105
+ */
106
+ const content = computed<{
107
+ key: BaseTextKey;
108
+ workflow?: { id: string; name: string };
109
+ version?: string;
110
+ note: string | null;
111
+ testId: string;
112
+ namesActor: boolean;
113
+ } | null>(() => {
114
+ const entry = props.entry;
115
+ // Read-time lookup shared by every sentence that may name a workflow
116
+ const workflowId = entry.data && 'workflowId' in entry.data ? entry.data.workflowId : null;
117
+ const linked = workflowId === null ? undefined : linkedWorkflows.value.get(workflowId);
118
+ const workflow =
119
+ workflowId !== null && linked ? { id: workflowId, name: linked.workflowName } : undefined;
120
+
121
+ switch (entry.type) {
122
+ case 'review.opened':
123
+ return {
124
+ key: 'workflowReviews.detail.activity.opened',
125
+ note: null,
126
+ testId: 'workflow-review-activity-opened',
127
+ namesActor: true,
128
+ };
129
+ case 'review.changes_requested':
130
+ return {
131
+ key: 'workflowReviews.detail.activity.changesRequested',
132
+ note: entry.data?.note ?? null,
133
+ testId: 'workflow-review-activity-changes-requested',
134
+ namesActor: true,
135
+ };
136
+ case 'review.approved':
137
+ return {
138
+ key: 'workflowReviews.detail.activity.approved',
139
+ note: entry.data?.note ?? null,
140
+ testId: 'workflow-review-activity-approved',
141
+ namesActor: true,
142
+ };
143
+ case 'review.version_updated':
144
+ return {
145
+ key: workflow
146
+ ? 'workflowReviews.detail.activity.versionUpdated.named'
147
+ : 'workflowReviews.detail.activity.versionUpdated',
148
+ workflow,
149
+ note: null,
150
+ testId: 'workflow-review-activity-version-updated',
151
+ namesActor: true,
152
+ };
153
+ case 'review.closed':
154
+ // Rendered as {@link closedCallout}, never as a sentence.
155
+ return null;
156
+ case 'workflow.archived':
157
+ case 'workflow.deleted':
158
+ case 'workflow.moved': {
159
+ const keys = causeKeys[entry.type];
160
+ const namesActor = entry.data?.actorKind === 'user';
161
+ return {
162
+ key: namesActor
163
+ ? workflow
164
+ ? keys.userNamed
165
+ : keys.user
166
+ : workflow
167
+ ? keys.systemNamed
168
+ : keys.system,
169
+ workflow,
170
+ note: null,
171
+ testId: keys.testId,
172
+ namesActor,
173
+ };
174
+ }
175
+ case 'workflow.published': {
176
+ if (!entry.data) return null;
177
+ const versionName =
178
+ linked?.pinnedVersionId === entry.data.workflowVersionId ? linked.pinnedVersionName : null;
179
+ return {
180
+ key: 'workflowReviews.detail.activity.workflowPublished',
181
+ workflow,
182
+ // The shared version-label convention: user-given name, id-derived fallback.
183
+ version: getVersionLabel({
184
+ workflowHistory: { versionId: entry.data.workflowVersionId, name: versionName },
185
+ }),
186
+ note: null,
187
+ testId: 'workflow-review-activity-workflow-published',
188
+ namesActor: true,
189
+ };
190
+ }
191
+ }
192
+ return null;
193
+ });
194
+
195
+ const actorName = computed(() =>
196
+ formatActorName(
197
+ props.entry.createdBy,
198
+ i18n.baseText('workflowReviews.detail.activity.unknownAuthor'),
199
+ ),
200
+ );
201
+ </script>
202
+
203
+ <template>
204
+ <N8nCallout
205
+ v-if="closedCallout"
206
+ theme="secondary"
207
+ :class="$style.closedCallout"
208
+ data-test-id="workflow-review-activity-closed"
209
+ >
210
+ <div :class="$style.closedCalloutContent">
211
+ <N8nText bold size="medium">{{ closedCallout.title }}</N8nText>
212
+ <N8nText size="medium">
213
+ <I18nT :keypath="closedCallout.bodyKey" scope="global">
214
+ <template #timestamp>
215
+ <time :datetime="entry.createdAt">
216
+ <N8nTimeAgo :date="entry.createdAt" :locale="rootStore.defaultLocale" />
217
+ </time>
218
+ </template>
219
+ </I18nT>
220
+ </N8nText>
221
+ </div>
222
+ </N8nCallout>
223
+ <div v-else-if="content" :class="[$style.entry, content.note && $style.boxed]">
224
+ <WorkflowReviewActivityActorAvatar v-if="content.namesActor" :actor="entry.createdBy" />
225
+ <!-- Fills the avatar's column, so an actorless (system) sentence stays in line -->
226
+ <div
227
+ v-else
228
+ :class="$style.systemIcon"
229
+ aria-hidden="true"
230
+ data-test-id="workflow-review-activity-system-icon"
231
+ >
232
+ <N8nIcon icon="info" :size="16" color="text-light" />
233
+ </div>
234
+ <!-- Column beside the avatar, as in the comment entry, so a note lines up with the
235
+ sentence above it instead of starting back at the avatar. -->
236
+ <div :class="$style.content">
237
+ <div :class="$style.headline">
238
+ <N8nText
239
+ v-if="content.namesActor"
240
+ size="medium"
241
+ color="text-dark"
242
+ :class="$style.line"
243
+ data-test-id="workflow-review-activity-actor"
244
+ >
245
+ {{ actorName }}
246
+ <!-- Inside the name so it disappears with it, rather than leaving a dangling
247
+ separator on the actorless entries. -->
248
+ <span aria-hidden="true" :class="$style.separator">|</span>
249
+ </N8nText>
250
+ <N8nText
251
+ size="medium"
252
+ color="text-light"
253
+ :class="$style.line"
254
+ :data-test-id="content.testId"
255
+ >
256
+ <I18nT :keypath="content.key" scope="global">
257
+ <template v-if="content.workflow" #workflowName>
258
+ <WorkflowReviewActivityWorkflowLink
259
+ :workflow-id="content.workflow.id"
260
+ :workflow-name="content.workflow.name"
261
+ :version="content.version"
262
+ />
263
+ </template>
264
+ <!-- `content.version` is only ever set for workflow.published, so this is reached
265
+ only when that entry's workflow has since been deleted. -->
266
+ <template v-else-if="content.version" #workflowName
267
+ ><i :class="$style.unknownWorkflow">{{
268
+ i18n.baseText('workflowReviews.detail.activity.unknownWorkflow')
269
+ }}</i>
270
+ {{ content.version }}</template
271
+ >
272
+ </I18nT>
273
+ </N8nText>
274
+ <N8nText size="medium" color="text-light" :class="$style.timeStamp">
275
+ <time :datetime="entry.createdAt">
276
+ <N8nTimeAgo :date="entry.createdAt" :locale="rootStore.defaultLocale" />
277
+ </time>
278
+ </N8nText>
279
+ </div>
280
+ <N8nText
281
+ v-if="content.note"
282
+ size="medium"
283
+ color="text-dark"
284
+ :class="[$style.body, $style.line]"
285
+ data-test-id="workflow-review-activity-note"
286
+ >
287
+ {{ content.note }}
288
+ </N8nText>
289
+ </div>
290
+ </div>
291
+ <WorkflowReviewActivityFallback v-else />
292
+ </template>
293
+
294
+ <style lang="scss" module>
295
+ @use '../activity-card' as *;
296
+
297
+ .entry {
298
+ @include activity-row;
299
+ }
300
+
301
+ .systemIcon {
302
+ @include activity-avatar;
303
+
304
+ display: flex;
305
+ width: var(--review-activity--avatar-size);
306
+ height: var(--review-activity--avatar-size);
307
+ align-items: center;
308
+ justify-content: center;
309
+ }
310
+
311
+ .content {
312
+ display: flex;
313
+ flex-direction: column;
314
+ gap: var(--spacing--3xs);
315
+ min-width: 0;
316
+ }
317
+
318
+ /* The separator carries its own wider spacing. */
319
+ .headline {
320
+ @include activity-headline;
321
+ }
322
+
323
+ .separator {
324
+ margin-inline: var(--spacing--3xs);
325
+ color: var(--text-color--disabled);
326
+ }
327
+
328
+ .unknownWorkflow {
329
+ font-style: italic;
330
+ }
331
+
332
+ .timeStamp {
333
+ padding-left: var(--spacing--4xs);
334
+ }
335
+
336
+ /* A decision that carries a note sits in a card, as a comment always does. */
337
+ .boxed {
338
+ @include activity-card;
339
+ }
340
+
341
+ /* line up the callout's edges line up with the comment cards */
342
+ .closedCallout {
343
+ margin-inline: calc(-1 * var(--spacing--sm));
344
+ }
345
+
346
+ .closedCalloutContent {
347
+ display: flex;
348
+ flex-direction: column;
349
+ gap: var(--spacing--5xs);
350
+ }
351
+
352
+ /* The same line the avatar beside it is centred on, so the two cannot drift apart. */
353
+ .line {
354
+ line-height: var(--review-activity--line-height);
355
+ }
356
+
357
+ .body {
358
+ white-space: pre-wrap;
359
+ overflow-wrap: anywhere;
360
+ }
361
+ </style>
@@ -0,0 +1,49 @@
1
+ <script setup lang="ts">
2
+ import { N8nIcon, N8nText } from '@n8n/design-system';
3
+ import { useI18n } from '@n8n/i18n';
4
+
5
+ // The registry binds `:entry` to every activity component; without this it would
6
+ // fall through to the root element as `entry="[object Object]"`.
7
+ defineOptions({ inheritAttrs: false });
8
+
9
+ const i18n = useI18n();
10
+ </script>
11
+
12
+ <template>
13
+ <div :class="$style.entry">
14
+ <!-- Fills the avatar's column like other entries, so the timeline stays on its grid. -->
15
+ <div :class="$style.icon" aria-hidden="true">
16
+ <N8nIcon icon="circle-help" :size="16" color="text-light" />
17
+ </div>
18
+ <N8nText
19
+ size="medium"
20
+ color="text-light"
21
+ :class="$style.line"
22
+ data-test-id="workflow-review-activity-unknown"
23
+ >
24
+ {{ i18n.baseText('workflowReviews.detail.activity.unknownEntry') }}
25
+ </N8nText>
26
+ </div>
27
+ </template>
28
+
29
+ <style lang="scss" module>
30
+ @use '../activity-card' as *;
31
+
32
+ .entry {
33
+ @include activity-row;
34
+ }
35
+
36
+ .icon {
37
+ @include activity-avatar;
38
+
39
+ display: flex;
40
+ width: var(--review-activity--avatar-size);
41
+ height: var(--review-activity--avatar-size);
42
+ align-items: center;
43
+ justify-content: center;
44
+ }
45
+
46
+ .line {
47
+ line-height: var(--review-activity--line-height);
48
+ }
49
+ </style>
@@ -0,0 +1,36 @@
1
+ <script setup lang="ts">
2
+ import { N8nIcon, N8nLink } from '@n8n/design-system';
3
+ import { computed } from 'vue';
4
+
5
+ import { VIEWS } from '@n8n/frontend-constants/views';
6
+
7
+ const props = defineProps<{
8
+ workflowId: string;
9
+ workflowName: string;
10
+ version?: string;
11
+ }>();
12
+
13
+ const label = computed(() =>
14
+ props.version ? `${props.workflowName} ${props.version}` : props.workflowName,
15
+ );
16
+ </script>
17
+
18
+ <template>
19
+ <N8nLink
20
+ :to="{ name: VIEWS.WORKFLOW, params: { workflowId } }"
21
+ theme="text"
22
+ size="medium"
23
+ data-test-id="workflow-review-activity-workflow-link"
24
+ >
25
+ <N8nIcon icon="workflow" size="medium" :class="$style.icon" />{{ label }}
26
+ </N8nLink>
27
+ </template>
28
+
29
+ <style lang="scss" module>
30
+ .icon {
31
+ margin-left: var(--spacing--4xs);
32
+ margin-right: var(--spacing--4xs);
33
+ /* Inline with the sentence; the offset optically centres the icon on the text. */
34
+ vertical-align: -0.15em;
35
+ }
36
+ </style>
@@ -0,0 +1,27 @@
1
+ import type { WorkflowReviewActivityEntry, WorkflowReviewActivityType } from '@n8n/api-types';
2
+ import type { Component } from 'vue';
3
+
4
+ import WorkflowReviewActivityComment from './activity-entries/WorkflowReviewActivityComment.vue';
5
+ import WorkflowReviewActivityFallback from './activity-entries/WorkflowReviewActivityFallback.vue';
6
+ import WorkflowReviewActivityEventEntry from './activity-entries/WorkflowReviewActivityEventEntry.vue';
7
+
8
+ // Total over the union so a new type cannot ship without a renderer; the runtime lookup still
9
+ // falls back for a stored type outside the union (e.g. after a version downgrade).
10
+ type ActivityEntryRegistry = Record<WorkflowReviewActivityType, Record<number, Component>>;
11
+
12
+ const registry: ActivityEntryRegistry = {
13
+ 'comment.created': { 1: WorkflowReviewActivityComment },
14
+ 'review.opened': { 1: WorkflowReviewActivityEventEntry },
15
+ 'review.changes_requested': { 1: WorkflowReviewActivityEventEntry },
16
+ 'review.approved': { 1: WorkflowReviewActivityEventEntry },
17
+ 'review.version_updated': { 1: WorkflowReviewActivityEventEntry },
18
+ 'review.closed': { 1: WorkflowReviewActivityEventEntry },
19
+ 'workflow.archived': { 1: WorkflowReviewActivityEventEntry },
20
+ 'workflow.deleted': { 1: WorkflowReviewActivityEventEntry },
21
+ 'workflow.moved': { 1: WorkflowReviewActivityEventEntry },
22
+ 'workflow.published': { 1: WorkflowReviewActivityEventEntry },
23
+ };
24
+
25
+ export function resolveActivityComponent(entry: WorkflowReviewActivityEntry): Component {
26
+ return registry[entry.type]?.[entry.typeVersion] ?? WorkflowReviewActivityFallback;
27
+ }
@@ -0,0 +1,41 @@
1
+ import type { ComputedRef, InjectionKey, Ref } from 'vue';
2
+
3
+ export const WORKFLOW_REVIEW_REQUESTS_VIEW = 'WorkflowReviewRequestsView';
4
+
5
+ /** What a feed entry may say about a linked workflow: its live name and its pinned version. */
6
+ export type ReviewLinkedWorkflowContext = {
7
+ workflowName: string;
8
+ pinnedVersionId: string | null;
9
+ /** The user-given version name, e.g. "Release candidate"; null when never named or pruned. */
10
+ pinnedVersionName: string | null;
11
+ };
12
+
13
+ /**
14
+ * The review's linked workflows, keyed by workflow id. Provided by the detail surface, read
15
+ * by feed entries that mention a workflow. Resolved at read time on purpose: feed payloads
16
+ * are immutable and never snapshot names, so a rename shows up everywhere at once. A workflow
17
+ * deleted since the entry was written has no row here; the entry copy degrades.
18
+ */
19
+ export const ReviewLinkedWorkflowsKey: InjectionKey<
20
+ ComputedRef<Map<string, ReviewLinkedWorkflowContext>>
21
+ > = Symbol('reviewLinkedWorkflows');
22
+
23
+ /** The detail pane's scroll area, which the activity feed scrolls in. */
24
+ export const ReviewDetailScrollContainerKey: InjectionKey<Readonly<Ref<HTMLElement | null>>> =
25
+ Symbol('reviewDetailScrollContainer');
26
+
27
+ /**
28
+ * Routing contract for the review inbox.
29
+ *
30
+ * Path: /inbox/reviews/:reviewId. Use /inbox with nothing selected.
31
+ * Selection always navigates with router.replace.
32
+ *
33
+ * Query params hold filter state; adding a filter must not require a routing
34
+ * change — reserve the key here, hydrate it in the view, and write it with
35
+ * router.replace({ query }) preserving params.
36
+ * - `state`: 'open' | 'closed'. Default 'open' is omitted from the URL.
37
+ * - `tab`: 'activity' | 'changes' — detail-pane tab. Default 'activity' is
38
+ * omitted from the URL.
39
+ * - Reserved for later filters: `q`, `projectId`, `author`, `reviewer`.
40
+ */
41
+ export const REVIEW_INBOX_QUERY_PARAM = { state: 'state', tab: 'tab' } as const;