@n8n/frontend-module-inbox 0.2.4 → 1.0.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.
@@ -1,19 +1,351 @@
1
1
  <script setup lang="ts">
2
- import { N8nEmptyState } from '@n8n/design-system';
2
+ import type { SelfHealingResultActionResponse, SelfHealingResultDetail } from '@n8n/api-types';
3
+ import { useToast } from '@n8n/composables/useToast';
4
+ import { N8nButton, N8nCallout, N8nEmptyState, N8nLoading } from '@n8n/design-system';
5
+ import { VIEWS } from '@n8n/frontend-constants/views';
6
+ import {
7
+ capabilities,
8
+ capabilityRegistry,
9
+ type SelfHealingChatStartOutcome,
10
+ } from '@n8n/frontend-module-sdk';
3
11
  import { useI18n } from '@n8n/i18n';
12
+ import { ResponseError } from '@n8n/rest-api-client';
13
+ import { useRootStore } from '@n8n/stores/useRootStore';
14
+ import { storeToRefs } from 'pinia';
15
+ import { computed, ref, watch } from 'vue';
16
+ import { useRouter } from 'vue-router';
4
17
 
5
- import type { InboxSelection } from '../inbox.constants';
18
+ import type { InboxItemChange } from '../inbox.constants';
19
+ import SelfHealingResultContent from './SelfHealingResultContent.vue';
20
+ import { useSelfHealingResultStore } from './selfHealingResult.store';
21
+ import {
22
+ fetchSelfHealingResult,
23
+ reviewSelfHealingResult,
24
+ type SelfHealingPublishAccess,
25
+ type SelfHealingResultAction,
26
+ type SelfHealingReviewAction,
27
+ type SelfHealingSelection,
28
+ } from './selfHealingResults.api';
6
29
 
7
- // AST-1518 replaces this rollout-gated integration point with the report and actions.
8
- defineProps<{ selection: Extract<InboxSelection, { type: 'self_healing_result' }> }>();
30
+ type ChatRetry = {
31
+ target: SelfHealingSelection;
32
+ projectId: string;
33
+ result: SelfHealingResultDetail;
34
+ reason: Exclude<SelfHealingChatStartOutcome, 'started'> | 'unconfirmed';
35
+ };
36
+
37
+ const props = defineProps<{
38
+ selection: SelfHealingSelection;
39
+ tab: 'activity' | 'changes';
40
+ onItemChange: (change: InboxItemChange) => void;
41
+ isSelected: (id: string) => boolean;
42
+ }>();
43
+ const emit = defineEmits<{ 'update:tab': [tab: 'activity' | 'changes'] }>();
44
+
45
+ const store = useSelfHealingResultStore();
46
+ const { detail, workflow, loading, error, workflowError, pendingAction } = storeToRefs(store);
47
+ const rootStore = useRootStore();
48
+ const router = useRouter();
9
49
  const i18n = useI18n();
50
+ const { showError, showMessage } = useToast();
51
+ const chat = capabilityRegistry.tryUse(capabilities.createSelfHealingChatHandoff)?.();
52
+ const chatRetry = ref<ChatRetry | null>(null);
53
+ let reconcileOnRetry: ((current: SelfHealingResultDetail) => Promise<boolean>) | null = null;
54
+
55
+ const workflowName = computed(
56
+ () =>
57
+ workflow.value?.name ??
58
+ detail.value?.suggestion?.payload.original.name ??
59
+ props.selection.workflowId,
60
+ );
61
+ const publishAccess = computed<SelfHealingPublishAccess>(() => {
62
+ if (!workflow.value) return 'unknown';
63
+ return workflow.value.scopes.includes('workflow:publish') ? 'allowed' : 'denied';
64
+ });
65
+ const errorKind = computed(() => {
66
+ if (error.value instanceof ResponseError) {
67
+ if (error.value.httpStatusCode === 404) return 'notFound';
68
+ if (error.value.httpStatusCode === 403) return 'forbidden';
69
+ }
70
+ return 'loadError';
71
+ });
72
+
73
+ watch(
74
+ [() => props.selection.id, () => props.selection.projectId, () => props.selection.workflowId],
75
+ () => {
76
+ reconcileOnRetry = null;
77
+ chatRetry.value = null;
78
+ void store.select(props.selection);
79
+ },
80
+ { immediate: true },
81
+ );
82
+
83
+ /** A client error status means the server rejected the action, so nothing was saved. */
84
+ function isRejected(cause: unknown) {
85
+ return (
86
+ cause instanceof ResponseError &&
87
+ cause.httpStatusCode !== undefined &&
88
+ cause.httpStatusCode < 500
89
+ );
90
+ }
91
+
92
+ function notifyResultClosed(
93
+ before: SelfHealingResultDetail,
94
+ after: SelfHealingResultDetail,
95
+ onItemChange: (change: InboxItemChange) => void,
96
+ ) {
97
+ if (before.reviewState === 'open' && after.reviewState !== 'open') {
98
+ onItemChange({
99
+ type: 'self_healing_result',
100
+ id: after.resultId,
101
+ state: 'closed',
102
+ updatedAt: after.updatedAt,
103
+ });
104
+ }
105
+ }
106
+
107
+ async function openEditor(workflowId: string) {
108
+ try {
109
+ const failure = await router.push({ name: VIEWS.WORKFLOW, params: { workflowId } });
110
+ if (failure) throw failure;
111
+ } catch (cause) {
112
+ showError(cause, i18n.baseText('inbox.selfHealing.action.openEditorError'));
113
+ }
114
+ }
115
+
116
+ async function startChat(request: Omit<ChatRetry, 'reason'>) {
117
+ let outcome: SelfHealingChatStartOutcome;
118
+ try {
119
+ outcome = (await chat?.start(request.projectId, request.result)) ?? 'unavailable';
120
+ } catch (cause) {
121
+ outcome = 'failed';
122
+ if (store.isSelected(request.target)) {
123
+ showError(cause, i18n.baseText('inbox.selfHealing.action.chatError'));
124
+ }
125
+ }
126
+
127
+ if (!store.isSelected(request.target)) return;
128
+ chatRetry.value = outcome === 'started' ? null : { ...request, reason: outcome };
129
+ }
130
+
131
+ async function retryChat() {
132
+ const retry = chatRetry.value;
133
+ if (!retry || !chat?.available.value || !store.isSelected(retry.target)) return;
134
+ if (!store.beginAction(retry.target, 'chat')) return;
135
+
136
+ try {
137
+ await startChat(retry);
138
+ } finally {
139
+ store.endAction(retry.target);
140
+ }
141
+ }
142
+
143
+ async function retryDetail() {
144
+ if (pendingAction.value) return;
145
+
146
+ const reconcile = reconcileOnRetry;
147
+ const result = await store.refreshDetail();
148
+ if (!reconcile || !result || reconcileOnRetry !== reconcile) return;
149
+
150
+ reconcileOnRetry = null;
151
+ await reconcile(result);
152
+ }
153
+
154
+ async function onAction(action: SelfHealingResultAction) {
155
+ const before = detail.value;
156
+ if (before?.reviewState !== 'open' || pendingAction.value || !store.isSelected(props.selection))
157
+ return;
158
+
159
+ if (
160
+ action === 'approve-and-publish' &&
161
+ (publishAccess.value !== 'allowed' || before.outcome !== 'fix_ready')
162
+ )
163
+ return;
164
+ if (action === 'chat' && !chat?.available.value) return;
165
+
166
+ const target = { ...props.selection };
167
+ if (!store.beginAction(target, action)) return;
168
+
169
+ const isSelected = props.isSelected;
170
+ const onItemChange = props.onItemChange;
171
+ const isContinuation = action === 'chat' || action === 'editor';
172
+
173
+ let reviewAction: SelfHealingReviewAction;
174
+ if (isContinuation) {
175
+ reviewAction = before.suggestion ? 'apply' : 'dismiss';
176
+ } else {
177
+ reviewAction = action;
178
+ }
179
+ const closedState = reviewAction === 'dismiss' ? 'dismissed' : 'applied';
180
+ let chatProjectId: string | null = null;
181
+
182
+ /**
183
+ * Without a response, the saved state does not show which request closed the result.
184
+ * So the chat waits for an explicit start, and publication stays unconfirmed.
185
+ */
186
+ async function continueAfterClose(
187
+ result: SelfHealingResultActionResponse,
188
+ responseLost: boolean,
189
+ ) {
190
+ // An outdated or concurrently discarded suggestion leaves nothing to continue with.
191
+ const closed =
192
+ result.reviewState === closedState ||
193
+ (action === 'dismiss' && result.reviewState === 'discarded');
194
+ if (!isSelected(target.id) || !closed) return false;
195
+
196
+ if (chatProjectId) {
197
+ const request = { target, projectId: chatProjectId, result };
198
+ if (responseLost) chatRetry.value = { ...request, reason: 'unconfirmed' };
199
+ else await startChat(request);
200
+ } else if (action !== 'dismiss') {
201
+ if (responseLost && action === 'approve-and-publish') {
202
+ showMessage({
203
+ type: 'warning',
204
+ duration: 0,
205
+ title: i18n.baseText('inbox.selfHealing.action.publishUnconfirmed.title'),
206
+ message: i18n.baseText('inbox.selfHealing.action.publishUnconfirmed.message'),
207
+ });
208
+ } else if (result.publishError !== undefined) {
209
+ showMessage({
210
+ type: 'warning',
211
+ duration: 0,
212
+ title: i18n.baseText('inbox.selfHealing.action.publishError'),
213
+ message: result.publishError,
214
+ });
215
+ }
216
+
217
+ await openEditor(result.workflowId);
218
+ }
219
+ return true;
220
+ }
221
+
222
+ try {
223
+ if (action === 'chat') {
224
+ // Load the chat project before closing, so a failed lookup leaves the result open.
225
+ chatProjectId = (await chat?.resolveProjectId().catch(() => null)) ?? null;
226
+ if (!isSelected(target.id)) return;
227
+ if (!chatProjectId) {
228
+ showMessage({
229
+ type: 'error',
230
+ title: i18n.baseText('inbox.selfHealing.action.chatError'),
231
+ message: i18n.baseText('inbox.selfHealing.action.chatProjectError'),
232
+ });
233
+ return;
234
+ }
235
+ }
236
+
237
+ const result = await reviewSelfHealingResult(rootStore.restApiContext, target, reviewAction);
238
+
239
+ store.acceptResult(result);
240
+ await continueAfterClose(result, false);
241
+ notifyResultClosed(before, result, onItemChange);
242
+ } catch (cause) {
243
+ // Refresh saved state before the user retries a request with a lost response.
244
+ const reconcile = async (current: SelfHealingResultDetail) => {
245
+ const continued = !isRejected(cause) && (await continueAfterClose(current, true));
246
+ notifyResultClosed(before, current, onItemChange);
247
+ return continued;
248
+ };
249
+
250
+ try {
251
+ const current = await fetchSelfHealingResult(rootStore.restApiContext, target);
252
+
253
+ store.acceptResult(current);
254
+ if (isSelected(target.id)) void store.refreshWorkflow();
255
+ if (await reconcile(current)) return;
256
+ } catch (readError) {
257
+ store.setError(target, readError);
258
+ if (isSelected(target.id)) reconcileOnRetry = reconcile;
259
+ }
260
+
261
+ if (isSelected(target.id)) showError(cause, i18n.baseText('inbox.selfHealing.action.error'));
262
+ } finally {
263
+ store.endAction(target);
264
+ }
265
+ }
10
266
  </script>
11
267
 
12
268
  <template>
13
- <N8nEmptyState
14
- :heading="i18n.baseText('inbox.resultUnavailable.title')"
15
- :description="i18n.baseText('inbox.resultUnavailable.body')"
269
+ <section
270
+ :class="$style.detail"
16
271
  data-test-id="inbox-self-healing-detail"
17
272
  :data-result-id="selection.id"
18
- />
273
+ >
274
+ <template v-if="loading || error || !detail">
275
+ <div :class="$style.columnTitle" />
276
+ <N8nLoading v-if="loading" :loading="true" :rows="3" />
277
+ <div v-else-if="error" :class="$style.error" data-test-id="self-healing-detail-error">
278
+ <N8nEmptyState
279
+ :heading="i18n.baseText(`inbox.selfHealing.detail.${errorKind}.title`)"
280
+ :description="i18n.baseText(`inbox.selfHealing.detail.${errorKind}.body`)"
281
+ />
282
+ <N8nButton :loading="pendingAction !== null" @click="retryDetail()">{{
283
+ i18n.baseText('generic.retry')
284
+ }}</N8nButton>
285
+ </div>
286
+ </template>
287
+ <template v-else>
288
+ <SelfHealingResultContent
289
+ :detail="detail"
290
+ :workflow-name="workflowName"
291
+ :tab="tab"
292
+ :pending-action="pendingAction"
293
+ :publish-access="publishAccess"
294
+ :can-chat="chat?.available.value ?? false"
295
+ @update:tab="emit('update:tab', $event)"
296
+ @action="onAction"
297
+ >
298
+ <template v-if="workflowError || chatRetry" #notice>
299
+ <N8nCallout v-if="workflowError" theme="warning">
300
+ {{ i18n.baseText('inbox.selfHealing.detail.metadataError') }}
301
+ <template #trailingContent>
302
+ <N8nButton variant="ghost" @click="store.refreshWorkflow()">{{
303
+ i18n.baseText('generic.retry')
304
+ }}</N8nButton>
305
+ </template>
306
+ </N8nCallout>
307
+ <N8nCallout
308
+ v-if="chatRetry"
309
+ :theme="chatRetry.reason === 'unconfirmed' ? 'info' : 'warning'"
310
+ data-test-id="self-healing-chat-retry"
311
+ >
312
+ {{ i18n.baseText(`inbox.selfHealing.chatRetry.${chatRetry.reason}`) }}
313
+ <template #trailingContent>
314
+ <N8nButton
315
+ variant="ghost"
316
+ :disabled="!chat?.available.value"
317
+ :loading="pendingAction === 'chat'"
318
+ data-test-id="self-healing-chat-retry-button"
319
+ @click="retryChat()"
320
+ >{{ i18n.baseText('inbox.selfHealing.action.continueWithAssistant') }}</N8nButton
321
+ >
322
+ </template>
323
+ </N8nCallout>
324
+ </template>
325
+ </SelfHealingResultContent>
326
+ </template>
327
+ </section>
19
328
  </template>
329
+
330
+ <style lang="scss" module>
331
+ .detail {
332
+ display: flex;
333
+ flex: 1;
334
+ flex-direction: column;
335
+ min-height: 0;
336
+ }
337
+
338
+ .columnTitle {
339
+ min-height: var(--spacing--2xl);
340
+ padding-bottom: var(--spacing--sm);
341
+ }
342
+
343
+ .error {
344
+ display: flex;
345
+ flex-direction: column;
346
+ align-items: center;
347
+ justify-content: center;
348
+ gap: var(--spacing--sm);
349
+ height: 100%;
350
+ }
351
+ </style>
@@ -0,0 +1,282 @@
1
+ <script setup lang="ts">
2
+ import type { SelfHealingResultDetail } from '@n8n/api-types';
3
+ import {
4
+ N8nAssistantAvatar,
5
+ N8nCard,
6
+ N8nIcon,
7
+ N8nLink,
8
+ N8nText,
9
+ N8nTimeAgo,
10
+ } from '@n8n/design-system';
11
+ import { VIEWS } from '@n8n/frontend-constants/views';
12
+ import { useI18n } from '@n8n/i18n';
13
+ import { useRootStore } from '@n8n/stores/useRootStore';
14
+ import { computed } from 'vue';
15
+
16
+ const props = defineProps<{
17
+ detail: SelfHealingResultDetail;
18
+ workflowName: string;
19
+ statusLabel: string;
20
+ }>();
21
+ const i18n = useI18n();
22
+ const rootStore = useRootStore();
23
+
24
+ const usageRows = computed(() => {
25
+ const usage = props.detail.usage;
26
+ const measurements =
27
+ 'credits' in usage
28
+ ? [
29
+ {
30
+ key: 'credits',
31
+ label: i18n.baseText('inbox.selfHealing.usage.credits'),
32
+ value: usage.credits,
33
+ },
34
+ ]
35
+ : [
36
+ {
37
+ key: 'promptTokens',
38
+ label: i18n.baseText('inbox.selfHealing.usage.promptTokens'),
39
+ value: usage.promptTokens,
40
+ },
41
+ {
42
+ key: 'completionTokens',
43
+ label: i18n.baseText('inbox.selfHealing.usage.completionTokens'),
44
+ value: usage.completionTokens,
45
+ },
46
+ {
47
+ key: 'totalTokens',
48
+ label: i18n.baseText('inbox.selfHealing.usage.totalTokens'),
49
+ value: usage.totalTokens,
50
+ },
51
+ ];
52
+
53
+ const rows = measurements.flatMap((row) => {
54
+ if (row.value === null) return [];
55
+
56
+ return [{ ...row, value: row.value.toLocaleString(rootStore.defaultLocale) }];
57
+ });
58
+
59
+ if (usage.durationSeconds !== null) {
60
+ rows.push({
61
+ key: 'duration',
62
+ label: i18n.baseText('inbox.selfHealing.usage.duration'),
63
+ value: i18n.baseText('inbox.selfHealing.usage.durationValue', {
64
+ interpolate: {
65
+ minutes: String(Math.floor(usage.durationSeconds / 60)),
66
+ seconds: String(Math.floor(usage.durationSeconds % 60)),
67
+ },
68
+ }),
69
+ });
70
+ }
71
+
72
+ return rows;
73
+ });
74
+ </script>
75
+
76
+ <template>
77
+ <aside :class="$style.metadata" data-test-id="self-healing-metadata">
78
+ <N8nCard :class="$style.card" data-test-id="self-healing-status-card">
79
+ <template #header>
80
+ <N8nText bold color="text-light" size="medium">{{
81
+ i18n.baseText('inbox.selfHealing.metadata.status')
82
+ }}</N8nText>
83
+ </template>
84
+ <N8nText size="medium">{{ statusLabel }}</N8nText>
85
+ </N8nCard>
86
+ <N8nCard :class="[$style.card, $style.peopleCard]">
87
+ <div :class="$style.section">
88
+ <N8nText bold color="text-light" size="medium">{{
89
+ i18n.baseText('inbox.selfHealing.metadata.createdBy')
90
+ }}</N8nText>
91
+ <div :class="$style.person">
92
+ <N8nAssistantAvatar size="small" />
93
+ <N8nText size="medium">{{
94
+ i18n.baseText('inbox.selfHealing.metadata.assistant')
95
+ }}</N8nText>
96
+ </div>
97
+ </div>
98
+ <div :class="$style.section">
99
+ <N8nText bold color="text-light" size="medium">{{
100
+ i18n.baseText('inbox.selfHealing.metadata.completed')
101
+ }}</N8nText>
102
+ <N8nText size="medium">
103
+ <time :datetime="detail.completedAt"
104
+ ><N8nTimeAgo :date="detail.completedAt" :locale="rootStore.defaultLocale"
105
+ /></time>
106
+ </N8nText>
107
+ </div>
108
+ </N8nCard>
109
+ <N8nCard :class="$style.card">
110
+ <template #header>
111
+ <N8nText bold color="text-light" size="medium">{{
112
+ i18n.baseText('inbox.selfHealing.metadata.workflow')
113
+ }}</N8nText>
114
+ </template>
115
+ <div :class="$style.section">
116
+ <N8nLink
117
+ :to="{ name: VIEWS.WORKFLOW, params: { workflowId: detail.workflowId } }"
118
+ theme="text"
119
+ size="medium"
120
+ :underline="false"
121
+ :class="$style.plainLink"
122
+ data-test-id="self-healing-workflow-link"
123
+ >
124
+ <span :class="$style.link">
125
+ <N8nIcon icon="workflow" size="medium" :class="$style.workflowIcon" />
126
+ <span :class="$style.workflowName">{{ workflowName }}</span>
127
+ </span>
128
+ </N8nLink>
129
+ </div>
130
+ <div :class="$style.section">
131
+ <N8nText bold color="text-light" size="medium">{{
132
+ i18n.baseText('inbox.selfHealing.metadata.execution')
133
+ }}</N8nText>
134
+ <N8nLink
135
+ v-if="detail.execution.status === 'available'"
136
+ :to="{
137
+ name: VIEWS.EXECUTION_PREVIEW,
138
+ params: { workflowId: detail.workflowId, executionId: detail.execution.id },
139
+ }"
140
+ theme="text"
141
+ size="medium"
142
+ :underline="false"
143
+ :class="$style.plainLink"
144
+ data-test-id="self-healing-execution-link"
145
+ >
146
+ {{
147
+ i18n.baseText('inbox.selfHealing.metadata.executionLink', {
148
+ interpolate: { id: detail.execution.id },
149
+ })
150
+ }}
151
+ </N8nLink>
152
+ <N8nText
153
+ v-else
154
+ size="small"
155
+ color="text-light"
156
+ data-test-id="self-healing-execution-unavailable"
157
+ >
158
+ {{ i18n.baseText('inbox.selfHealing.metadata.executionUnavailable') }}
159
+ </N8nText>
160
+ </div>
161
+ </N8nCard>
162
+ <N8nCard v-if="usageRows.length" :class="$style.card" data-test-id="self-healing-usage">
163
+ <template #header>
164
+ <N8nText bold color="text-light" size="medium">{{
165
+ i18n.baseText('inbox.selfHealing.usage.title')
166
+ }}</N8nText>
167
+ </template>
168
+ <dl :class="$style.usage">
169
+ <div
170
+ v-for="row in usageRows"
171
+ :key="row.key"
172
+ :class="$style.measurement"
173
+ :data-test-id="`self-healing-usage-${row.key}`"
174
+ >
175
+ <N8nText tag="dt" size="small" color="text-light">{{ row.label }}</N8nText>
176
+ <N8nText tag="dd" size="small">{{ row.value }}</N8nText>
177
+ </div>
178
+ </dl>
179
+ </N8nCard>
180
+ </aside>
181
+ </template>
182
+
183
+ <style module lang="scss">
184
+ .metadata {
185
+ display: flex;
186
+ /* Match the prototype's metadata rail. The design system has no column-width token. */
187
+ flex: 0 0 min(18rem, 30%);
188
+ min-width: 14rem;
189
+ flex-direction: column;
190
+ gap: var(--spacing--2xs);
191
+ padding-top: var(--spacing--5xs);
192
+ overflow: auto;
193
+ }
194
+
195
+ .card {
196
+ --card--padding: var(--spacing--xs);
197
+ --n8n--card-body--gap: var(--spacing--2xs);
198
+ align-items: stretch;
199
+ border-color: var(--border-color);
200
+ background-color: transparent;
201
+
202
+ > div {
203
+ gap: var(--spacing--4xs);
204
+ }
205
+ }
206
+
207
+ .peopleCard {
208
+ --n8n--card-body--gap: var(--spacing--sm);
209
+ }
210
+
211
+ .section {
212
+ display: flex;
213
+ flex-direction: column;
214
+ gap: var(--spacing--2xs);
215
+ min-width: 0;
216
+ }
217
+
218
+ .person,
219
+ .link {
220
+ display: flex;
221
+ align-items: center;
222
+ gap: var(--spacing--2xs);
223
+ min-width: 0;
224
+ }
225
+
226
+ .link {
227
+ align-items: flex-start;
228
+ gap: var(--spacing--4xs);
229
+ }
230
+
231
+ .plainLink:hover,
232
+ .plainLink:focus-visible {
233
+ text-decoration: underline;
234
+ }
235
+
236
+ .workflowIcon {
237
+ flex-shrink: 0;
238
+ /* Align the icon with the first line, as in the prototype. */
239
+ margin-top: calc((1lh - 1em) / 2);
240
+ }
241
+
242
+ .workflowName {
243
+ white-space: normal;
244
+ overflow-wrap: anywhere;
245
+ }
246
+
247
+ .usage {
248
+ display: flex;
249
+ flex-direction: column;
250
+ gap: var(--spacing--4xs);
251
+ margin: 0;
252
+ }
253
+
254
+ .measurement {
255
+ display: flex;
256
+ justify-content: space-between;
257
+ gap: var(--spacing--2xs);
258
+
259
+ dd {
260
+ margin: 0;
261
+ text-align: right;
262
+ font-variant-numeric: tabular-nums;
263
+ }
264
+ }
265
+
266
+ /* Match the prototype's sidebar widths. There are no column-width tokens. */
267
+ @media (max-width: 75rem) {
268
+ .metadata {
269
+ flex-basis: 15rem;
270
+ min-width: 12rem;
271
+ }
272
+ }
273
+
274
+ /* Match the prototype's detail breakpoint. Container queries cannot use CSS variables. */
275
+ @container assistant-detail (max-width: 44rem) {
276
+ .metadata {
277
+ flex: 0 0 auto;
278
+ min-width: 0;
279
+ overflow: visible;
280
+ }
281
+ }
282
+ </style>