@flamingo-stack/openframe-frontend-core 0.0.849 → 0.0.851

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 (140) hide show
  1. package/dist/build/export-map.json +1 -1
  2. package/dist/chat-protocol/events.d.ts +4 -1
  3. package/dist/chat-protocol/events.d.ts.map +1 -1
  4. package/dist/chat-protocol/index.cjs +90 -40
  5. package/dist/chat-protocol/nats-decoder.d.ts.map +1 -1
  6. package/dist/chat-protocol/nats-decoder.js +5 -1
  7. package/dist/chat-protocol/policy-review.d.ts +18 -0
  8. package/dist/chat-protocol/policy-review.d.ts.map +1 -0
  9. package/dist/chat-protocol/policy-review.js +55 -0
  10. package/dist/{chunk-C3P7IUTU.cjs → chunk-2NGW73T2.cjs} +5 -4
  11. package/dist/{chunk-SHQY4GOE.cjs → chunk-2WZHNMQY.cjs} +67 -67
  12. package/dist/{chunk-R5JPZ6TY.cjs → chunk-5SEJEBAB.cjs} +8 -8
  13. package/dist/{chunk-TX4NTH22.cjs → chunk-B6D4WWKD.cjs} +36 -36
  14. package/dist/{chunk-FXLROQUT.cjs → chunk-CDW6JTAQ.cjs} +442 -459
  15. package/dist/{chunk-5PIR77V5.cjs → chunk-DN227KZH.cjs} +4 -4
  16. package/dist/{chunk-MC3L525K.cjs → chunk-FGSN5UNY.cjs} +3 -3
  17. package/dist/{chunk-ZGEWT3SF.cjs → chunk-HXURNKXI.cjs} +3 -3
  18. package/dist/{chunk-FRG724DU.cjs → chunk-KRTTH3ET.cjs} +4 -4
  19. package/dist/{chunk-YJZ7R3W5.cjs → chunk-KXFIYO4O.cjs} +8 -8
  20. package/dist/{chunk-4YBS4HIO.cjs → chunk-LM7EL3UQ.cjs} +444 -27
  21. package/dist/{chunk-YYHQY3K6.cjs → chunk-NQEPLUUW.cjs} +21 -21
  22. package/dist/{chunk-KXBZCUDW.cjs → chunk-ODMZSAIY.cjs} +36 -36
  23. package/dist/{chunk-36XWCVSM.cjs → chunk-OUP7L3U5.cjs} +50 -50
  24. package/dist/{chunk-5CBI2ZYV.cjs → chunk-PJNEBSP5.cjs} +5 -5
  25. package/dist/{chunk-6AJYGXXS.cjs → chunk-PPCRLSVR.cjs} +11 -11
  26. package/dist/{chunk-SA2NNWIJ.cjs → chunk-QCSA7GIX.cjs} +17 -62
  27. package/dist/{chunk-PV2XQJ2R.cjs → chunk-SPKAXETT.cjs} +42 -42
  28. package/dist/{chunk-IKOL6NJY.cjs → chunk-U4RTWH5D.cjs} +53 -43
  29. package/dist/{chunk-IHY7E4EX.cjs → chunk-UFGPLXH6.cjs} +4 -4
  30. package/dist/{chunk-TEKN6TJ5.cjs → chunk-UQK6J3ZS.cjs} +13 -13
  31. package/dist/{chunk-S3QXLD4X.cjs → chunk-VCFQHPOO.cjs} +37 -3
  32. package/dist/{chunk-Z5UCDEKZ.cjs → chunk-VU6N3NZS.cjs} +6 -6
  33. package/dist/{chunk-ZY34R37A.cjs → chunk-VXIZCUNZ.cjs} +61 -61
  34. package/dist/{chunk-3W4ASAXN.cjs → chunk-WYROOMUZ.cjs} +2 -2
  35. package/dist/{chunk-YLETC7LP.cjs → chunk-ZEPEFKHA.cjs} +893 -973
  36. package/dist/{cloud-tenants-HKUUEUAY.cjs → cloud-tenants-XH6GV4EI.cjs} +12 -13
  37. package/dist/components/case-studies/index.cjs +24 -25
  38. package/dist/components/chat/approval-batch-message.d.ts +5 -0
  39. package/dist/components/chat/approval-batch-message.d.ts.map +1 -1
  40. package/dist/components/chat/approval-batch-message.js +36 -10
  41. package/dist/components/chat/index.cjs +8 -9
  42. package/dist/components/chat/policy-review.d.ts +27 -0
  43. package/dist/components/chat/policy-review.d.ts.map +1 -0
  44. package/dist/components/chat/policy-review.js +67 -0
  45. package/dist/components/chat/stream/message-mutations.d.ts.map +1 -1
  46. package/dist/components/chat/stream/message-mutations.js +4 -1
  47. package/dist/components/chat/tool-execution-display.d.ts.map +1 -1
  48. package/dist/components/chat/tool-execution-display.js +16 -5
  49. package/dist/components/chat/types/message.types.d.ts +40 -2
  50. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  51. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  52. package/dist/components/chat/utils/extract-incomplete-message-state.js +2 -0
  53. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  54. package/dist/components/chat/utils/message-segment-accumulator.js +3 -0
  55. package/dist/components/chat/utils/process-historical-messages.d.ts +2 -2
  56. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  57. package/dist/components/chat/utils/process-historical-messages.js +7 -1
  58. package/dist/components/contact/index.cjs +13 -14
  59. package/dist/components/docs/index.cjs +16 -17
  60. package/dist/components/embeds/embed-viewer-frame.d.ts +2 -0
  61. package/dist/components/embeds/embed-viewer-frame.d.ts.map +1 -1
  62. package/dist/components/embeds/embed-viewer-frame.js +12 -2
  63. package/dist/components/embeds/index.cjs +13 -14
  64. package/dist/components/embeds/pdf-viewer.d.ts.map +1 -1
  65. package/dist/components/embeds/pdf-viewer.js +2 -1
  66. package/dist/components/faq/index.cjs +14 -15
  67. package/dist/components/features/approval-level.d.ts +6 -12
  68. package/dist/components/features/approval-level.d.ts.map +1 -1
  69. package/dist/components/features/approval-level.js +45 -55
  70. package/dist/components/features/index.cjs +17 -15
  71. package/dist/components/features/notifications/approval-request-notification-tile.d.ts.map +1 -1
  72. package/dist/components/features/notifications/approval-request-notification-tile.js +71 -48
  73. package/dist/components/features/notifications/types.d.ts +2 -0
  74. package/dist/components/features/notifications/types.d.ts.map +1 -1
  75. package/dist/components/features/notifications/types.js +3 -1
  76. package/dist/components/features/policy-configuration-panel.d.ts.map +1 -1
  77. package/dist/components/features/policy-configuration-panel.js +75 -64
  78. package/dist/components/features/software/index.d.ts +1 -1
  79. package/dist/components/features/software/index.d.ts.map +1 -1
  80. package/dist/components/features/software/index.js +2 -0
  81. package/dist/components/features/software/software-action-copy.d.ts.map +1 -1
  82. package/dist/components/features/software/software-action-copy.js +6 -12
  83. package/dist/components/features/software/software-action-form.d.ts +7 -1
  84. package/dist/components/features/software/software-action-form.d.ts.map +1 -1
  85. package/dist/components/features/software/software-action-form.js +11 -3
  86. package/dist/components/features/software/update-settings-fields.d.ts +12 -1
  87. package/dist/components/features/software/update-settings-fields.d.ts.map +1 -1
  88. package/dist/components/features/software/update-settings-fields.js +57 -29
  89. package/dist/components/help-center-pages/index.cjs +78 -79
  90. package/dist/components/index.cjs +58 -58
  91. package/dist/components/meeting-scheduler/index.cjs +61 -61
  92. package/dist/components/navigation/index.cjs +15 -16
  93. package/dist/components/onboarding-guides/index.cjs +14 -15
  94. package/dist/components/product-demo/index.cjs +23 -24
  95. package/dist/components/tickets/index.cjs +17 -18
  96. package/dist/components/ui/file-manager/index.cjs +8 -9
  97. package/dist/components/ui/index.cjs +16 -18
  98. package/dist/components/ui/page-actions.d.ts.map +1 -1
  99. package/dist/components/ui/page-actions.js +11 -1
  100. package/dist/components/ui/radio-group.d.ts.map +1 -1
  101. package/dist/components/ui/radio-group.js +8 -2
  102. package/dist/components/ui/tab-selector.d.ts +7 -1
  103. package/dist/components/ui/tab-selector.d.ts.map +1 -1
  104. package/dist/components/ui/tab-selector.js +22 -3
  105. package/dist/{devices-P7M4Z7VP.cjs → devices-7PT6MPJI.cjs} +13 -14
  106. package/dist/{embeddable-chat-322NDFP5.cjs → embeddable-chat-5NY76K7K.cjs} +84 -85
  107. package/dist/index.cjs +24 -24
  108. package/dist/{knowledge-TC7JLOWI.cjs → knowledge-J6N7IEVI.cjs} +13 -14
  109. package/dist/{logs-GPOYCP4B.cjs → logs-D6KQP2BX.cjs} +19 -20
  110. package/dist/{policies-ZUHOUMDD.cjs → policies-J7MCOL3C.cjs} +16 -17
  111. package/dist/{remote-session-NT4NG7EF.cjs → remote-session-PXHA5KFK.cjs} +8 -9
  112. package/dist/{scripts-FOEVDGTH.cjs → scripts-VURXIHVS.cjs} +19 -20
  113. package/dist/{software-update-XGTMNFST.cjs → software-update-6SHUBDIE.cjs} +16 -17
  114. package/dist/{tickets-board-7FLGQEHF.cjs → tickets-board-NLCN7WNX.cjs} +9 -9
  115. package/package.json +1 -1
  116. package/src/chat-protocol/events.ts +4 -1
  117. package/src/chat-protocol/nats-decoder.ts +4 -0
  118. package/src/chat-protocol/policy-review.ts +71 -0
  119. package/src/components/chat/approval-batch-message.tsx +47 -10
  120. package/src/components/chat/policy-review.tsx +90 -0
  121. package/src/components/chat/stream/message-mutations.ts +4 -0
  122. package/src/components/chat/tool-execution-display.tsx +23 -4
  123. package/src/components/chat/types/message.types.ts +42 -2
  124. package/src/components/chat/utils/extract-incomplete-message-state.ts +2 -0
  125. package/src/components/chat/utils/message-segment-accumulator.ts +3 -0
  126. package/src/components/chat/utils/process-historical-messages.ts +14 -3
  127. package/src/components/embeds/embed-viewer-frame.tsx +10 -2
  128. package/src/components/embeds/pdf-viewer.tsx +4 -1
  129. package/src/components/features/approval-level.tsx +74 -78
  130. package/src/components/features/notifications/approval-request-notification-tile.tsx +75 -50
  131. package/src/components/features/notifications/types.ts +4 -0
  132. package/src/components/features/policy-configuration-panel.tsx +100 -83
  133. package/src/components/features/software/index.ts +1 -0
  134. package/src/components/features/software/software-action-copy.ts +19 -12
  135. package/src/components/features/software/software-action-form.tsx +16 -3
  136. package/src/components/features/software/update-settings-fields.tsx +104 -32
  137. package/src/components/ui/page-actions.tsx +19 -2
  138. package/src/components/ui/radio-group.tsx +20 -2
  139. package/src/components/ui/tab-selector.tsx +26 -3
  140. package/dist/chunk-N3MBQRCQ.cjs +0 -41
@@ -1,6 +1,5 @@
1
1
  'use client';
2
2
 
3
- import { ChevronDown, Shield } from 'lucide-react';
4
3
  import type { FC } from 'react';
5
4
  import { useState } from 'react';
6
5
  import type {
@@ -11,17 +10,21 @@ import type {
11
10
  } from '../../types/permissions';
12
11
  import { cn } from '../../utils/cn';
13
12
  import { useCollapsible } from '../chat/hooks/use-collapsible';
13
+ import { Chevron02DownIcon, ShieldIcon } from '../icons-v2-generated';
14
14
  import { TextLineSkeleton } from '../loading/unified-skeleton';
15
15
  import { ToolIcon } from '../tool-icon';
16
16
  import { Button } from '../ui/button';
17
- import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../ui/dropdown-menu';
17
+ import {
18
+ DropdownMenu,
19
+ DropdownMenuContent,
20
+ DropdownMenuItem,
21
+ DropdownMenuSeparator,
22
+ DropdownMenuTrigger,
23
+ } from '../ui/dropdown-menu';
18
24
  import { loadingItemProps, useLoadingScope } from '../ui/loading-scope';
19
- import { APPROVAL_LEVEL_META, APPROVAL_LEVELS } from './approval-level';
25
+ import { APPROVAL_LEVEL_META, APPROVAL_LEVELS, ApprovalLevelView } from './approval-level';
20
26
 
21
- const approvalLevelOptions = APPROVAL_LEVELS.map(value => ({ value, label: APPROVAL_LEVEL_META[value].label }));
22
-
23
- const POLICY_LEVEL_WIDTH = 'w-[180px]';
24
- const CATEGORY_LEVEL_WIDTH = 'w-[256px]';
27
+ const CATEGORY_LEVEL_WIDTH = 'w-[296px]';
25
28
 
26
29
  /** How many collapsed category rows the panel draws while loading when the host passes no categories. */
27
30
  export const POLICY_CONFIGURATION_PANEL_SKELETON_CATEGORIES = 4;
@@ -40,9 +43,22 @@ const CATEGORY_LEVEL_COLUMN_CLASS = cn(
40
43
  CATEGORY_LEVEL_WIDTH,
41
44
  );
42
45
  /** The expand / collapse control's chevron. */
43
- const CHEVRON_CLASS = 'h-6 w-6 text-ods-text-primary transition-transform duration-300';
46
+ const CHEVRON_CLASS = '!h-6 !w-6 text-ods-text-primary transition-transform duration-300';
47
+
48
+ /** A level as the dropdown writes it: its icon in its colour (the trigger's chevron size, 24px), then its name. */
49
+ const LevelOption: FC<{ level: ApprovalLevel }> = ({ level }) => {
50
+ const { label, Icon, toneClassName } = APPROVAL_LEVEL_META[level];
51
+ return (
52
+ <span className="inline-flex min-w-0 items-center gap-[var(--spacing-system-xsf)]">
53
+ <Icon size={24} className={cn('shrink-0', toneClassName)} aria-hidden />
54
+ <span className="truncate">{label}</span>
55
+ </span>
56
+ );
57
+ };
44
58
 
45
- const getApprovalLevelLabel = (level: ApprovalLevel): string => APPROVAL_LEVEL_META[level]?.label ?? level;
59
+ /** A dropdown item at its trigger's scale: the trigger's type and icon size, a field's height (48px). The menu is
60
+ * portalled to the window, so it steps with the window (`md:`), not the content. */
61
+ const LEVEL_MENU_ITEM_CLASS = 'p-[var(--spacing-system-sf)] text-h4 [&_svg]:size-4 md:[&_svg]:size-6';
46
62
 
47
63
  const ApprovalLevelDropdown: FC<{
48
64
  value: ApprovalLevel | undefined;
@@ -58,27 +74,31 @@ const ApprovalLevelDropdown: FC<{
58
74
  <Button
59
75
  variant="outline"
60
76
  disabled={disabled}
61
- rightIcon={<ChevronDown className="h-6 w-6 text-ods-text-secondary" />}
77
+ rightIcon={<Chevron02DownIcon className="!h-6 !w-6 text-ods-text-secondary" />}
62
78
  className={cn(
63
- 'flex h-auto items-center justify-between gap-[var(--spacing-system-xsf)] rounded-md border border-ods-border bg-ods-card !px-[var(--spacing-system-xsf)] py-[var(--spacing-system-xsf)] transition-colors !text-h6 hover:bg-ods-bg-hover',
79
+ 'flex h-auto items-center justify-between gap-[var(--spacing-system-xsf)] rounded-md border border-ods-border bg-ods-card !p-[var(--spacing-system-sf)] transition-colors !text-h4 hover:bg-ods-bg-hover',
64
80
  value ? 'text-ods-text-primary' : 'text-ods-text-secondary',
65
81
  triggerClassName,
66
82
  )}
67
83
  >
68
- {value ? getApprovalLevelLabel(value) : 'Set Global Permission'}
84
+ {value ? <LevelOption level={value} /> : 'Set Global Permission'}
69
85
  </Button>
70
86
  </DropdownMenuTrigger>
71
87
  <DropdownMenuContent align="end" className={contentClassName}>
72
- {allowClear && (
73
- <DropdownMenuItem onClick={() => onChange(undefined)} className="text-h6">
74
- Clear Global Permission
75
- </DropdownMenuItem>
76
- )}
77
- {approvalLevelOptions.map(option => (
78
- <DropdownMenuItem key={option.value} onClick={() => onChange(option.value)} className="text-h6">
79
- {option.label}
88
+ {APPROVAL_LEVELS.map(level => (
89
+ <DropdownMenuItem key={level} onClick={() => onChange(level)} className={LEVEL_MENU_ITEM_CLASS}>
90
+ <LevelOption level={level} />
80
91
  </DropdownMenuItem>
81
92
  ))}
93
+ {/* The way back out comes last, apart from the levels. */}
94
+ {allowClear && (
95
+ <>
96
+ <DropdownMenuSeparator />
97
+ <DropdownMenuItem onClick={() => onChange(undefined)} className={LEVEL_MENU_ITEM_CLASS}>
98
+ Clear Global Permission
99
+ </DropdownMenuItem>
100
+ </>
101
+ )}
82
102
  </DropdownMenuContent>
83
103
  </DropdownMenu>
84
104
  );
@@ -89,56 +109,49 @@ const PolicyRow: FC<{
89
109
  editMode: boolean;
90
110
  onPermissionChange: (categoryId: string, policyId: string, level: ApprovalLevel) => void;
91
111
  }> = ({ policy, categoryId, editMode, onPermissionChange }) => {
92
- const handleChange = (level: ApprovalLevel | undefined) => {
93
- if (level) onPermissionChange(categoryId, policy.id, level);
94
- };
112
+ const nameId = `policy-${categoryId}-${policy.id}`;
113
+ // The level as every guardrail surface draws it: its icon in its colour, then its name (Figma 1608-49525), or, while
114
+ // editing, its name in its colour beside the four levels' icons to pick from (Figma 4365-22663).
115
+ const level = policy.approvalLevel ? (
116
+ <ApprovalLevelView
117
+ value={policy.approvalLevel}
118
+ editable={editMode}
119
+ onChange={next => onPermissionChange(categoryId, policy.id, next)}
120
+ aria-labelledby={nameId}
121
+ />
122
+ ) : null;
95
123
 
96
124
  return (
97
125
  <div className="flex items-start gap-[var(--spacing-system-m)] border-b border-ods-border bg-ods-bg px-[var(--spacing-system-m)] py-[var(--spacing-system-sf)] last:border-b-0 content-md:items-center">
98
- {/* Tool Icon */}
99
- <div className={ICON_TILE_CLASS}>
126
+ {/* Tool Icon: grey, as the category's (Figma 1608-49526). Editing on a phone it moves beside the name, and the
127
+ strip gets the row's width (Figma 4365-22455). */}
128
+ <div className={cn(ICON_TILE_CLASS, 'text-ods-text-secondary', editMode && 'hidden content-md:flex')}>
100
129
  <ToolIcon toolType={policy.toolName} size={16} />
101
130
  </div>
102
131
 
103
- {/* Policy Info — on mobile the approval level stacks below the pattern */}
132
+ {/* Policy Info — on mobile the approval level stacks below the name */}
104
133
  <div className="flex min-w-0 flex-1 flex-col">
105
- <p className="truncate text-ods-text-primary text-h4" title={policy.name}>
106
- {policy.name}
107
- </p>
108
- <p className="break-all text-ods-text-secondary text-h6">{policy.commandPattern}</p>
109
- {editMode ? (
110
- <div className="mt-[var(--spacing-system-xsf)] content-md:hidden">
111
- <ApprovalLevelDropdown
112
- value={policy.approvalLevel}
113
- onChange={handleChange}
114
- triggerClassName="w-full"
115
- contentClassName={POLICY_LEVEL_WIDTH}
116
- />
117
- </div>
118
- ) : policy.approvalLevel ? (
119
- <span className="text-ods-text-primary text-h4 content-md:hidden">
120
- {getApprovalLevelLabel(policy.approvalLevel)}
121
- </span>
122
- ) : null}
123
- </div>
124
-
125
- {/* Approval Level column (desktop only) */}
126
- <div className="hidden shrink-0 content-md:block">
127
- {editMode ? (
128
- <ApprovalLevelDropdown
129
- value={policy.approvalLevel}
130
- onChange={handleChange}
131
- triggerClassName={POLICY_LEVEL_WIDTH}
132
- contentClassName={POLICY_LEVEL_WIDTH}
133
- />
134
- ) : (
135
- <div className={cn('px-[var(--spacing-system-sf)] py-[var(--spacing-system-xsf)]', POLICY_LEVEL_WIDTH)}>
136
- {policy.approvalLevel && (
137
- <span className="text-ods-text-primary text-h4">{getApprovalLevelLabel(policy.approvalLevel)}</span>
138
- )}
134
+ <div className="flex min-w-0 items-center gap-[var(--spacing-system-sf)]">
135
+ <div className="flex min-w-0 flex-1 flex-col">
136
+ {/* The rule's name only: its command pattern is a regex, not something to read. */}
137
+ <p id={nameId} className="truncate text-ods-text-primary text-h4" title={policy.name}>
138
+ {policy.name}
139
+ </p>
139
140
  </div>
141
+ {editMode && (
142
+ <span className="flex shrink-0 text-ods-text-secondary content-md:hidden">
143
+ <ToolIcon toolType={policy.toolName} size={16} />
144
+ </span>
145
+ )}
146
+ </div>
147
+ {level && (
148
+ <div className={cn('flex content-md:hidden', editMode && 'mt-[var(--spacing-system-sf)]')}>{level}</div>
140
149
  )}
141
150
  </div>
151
+
152
+ {/* Approval Level column (desktop only). A flex box, not a block: a block's line box around the inline level
153
+ stood taller than the level and set it above the row's centre. */}
154
+ {level && <div className="hidden shrink-0 items-center content-md:flex">{level}</div>}
142
155
  </div>
143
156
  );
144
157
  };
@@ -186,15 +199,21 @@ const CategorySection: FC<{
186
199
  {/* Category Header */}
187
200
  <div
188
201
  {...loadingItemProps(props)}
189
- className={cn(CATEGORY_HEADER_CLASS, !loading && 'cursor-pointer transition-colors hover:bg-ods-bg-hover')}
202
+ // The whole row toggles, so the whole row lights on hover; not while the pointer is on the bulk dropdown, which
203
+ // is its own target. Open, a rule under the row (on the frame below) ends the lit row at an edge.
204
+ className={cn(
205
+ CATEGORY_HEADER_CLASS,
206
+ !loading &&
207
+ 'cursor-pointer transition-colors hover:bg-ods-bg-hover [&:has([data-bulk-level]:hover)]:bg-ods-card',
208
+ )}
190
209
  onClick={loading ? undefined : () => onToggle(category.id)}
191
210
  >
192
211
  {/* Category Icon (static: the host's, else the default shield) */}
193
212
  <div className={ICON_TILE_CLASS}>
194
- <div className="text-ods-text-secondary">{category.icon ?? <Shield className="h-4 w-4" />}</div>
213
+ <div className="text-ods-text-secondary">{category.icon ?? <ShieldIcon size={16} />}</div>
195
214
  </div>
196
215
 
197
- {/* Category Info — on mobile the bulk dropdown stacks below the count */}
216
+ {/* Category Info. A phone has no bulk dropdown (Figma 4365-22446): its rules are set one by one. */}
198
217
  <div className={CATEGORY_TEXT_CLASS}>
199
218
  {category.name ? (
200
219
  <p className="truncate text-ods-text-primary text-h4" title={category.name}>
@@ -206,25 +225,15 @@ const CategorySection: FC<{
206
225
  {loading ? (
207
226
  <TextLineSkeleton type="h6" className="w-24" />
208
227
  ) : (
209
- <p className="text-ods-text-secondary text-h6">{category.policies.length} Configurations</p>
210
- )}
211
- {editMode && (
212
- <div className="mt-[var(--spacing-system-xsf)] content-md:hidden" onClick={e => e.stopPropagation()}>
213
- <ApprovalLevelDropdown
214
- value={bulkLevel}
215
- onChange={handleBulkChange}
216
- allowClear
217
- triggerClassName="w-full"
218
- contentClassName={CATEGORY_LEVEL_WIDTH}
219
- disabled={loading}
220
- />
221
- </div>
228
+ <p className="text-ods-text-secondary text-h6">
229
+ {category.policies.length} {category.policies.length === 1 ? 'Configuration' : 'Configurations'}
230
+ </p>
222
231
  )}
223
232
  </div>
224
233
 
225
234
  {/* Global Permission column (desktop only) */}
226
235
  {editMode ? (
227
- <div className="hidden shrink-0 content-md:block" onClick={e => e.stopPropagation()}>
236
+ <div data-bulk-level className="hidden shrink-0 content-md:block" onClick={e => e.stopPropagation()}>
228
237
  <ApprovalLevelDropdown
229
238
  value={bulkLevel}
230
239
  onChange={handleBulkChange}
@@ -247,8 +256,13 @@ const CategorySection: FC<{
247
256
  e.stopPropagation();
248
257
  onToggle(category.id);
249
258
  }}
250
- leftIcon={<ChevronDown className={cn(CHEVRON_CLASS, isExpanded && 'rotate-180')} />}
251
- className="h-auto w-auto shrink-0 rounded-md"
259
+ leftIcon={<Chevron02DownIcon className={cn(CHEVRON_CLASS, isExpanded && 'rotate-180')} />}
260
+ // 40px on a phone (Figma 4365-22452), 48 from content-md.
261
+ className={cn(
262
+ 'h-auto w-auto shrink-0 rounded-md !p-[var(--spacing-system-xsf)] content-md:!p-[var(--spacing-system-sf)]',
263
+ // The row is the hover; the chevron adds none of its own.
264
+ 'hover:bg-transparent active:bg-transparent',
265
+ )}
252
266
  aria-label={isExpanded ? 'Collapse' : 'Expand'}
253
267
  />
254
268
  </div>
@@ -264,7 +278,10 @@ const CategorySection: FC<{
264
278
  }}
265
279
  className={isExpanded ? 'opacity-100' : 'opacity-0'}
266
280
  >
267
- <div ref={innerRef} className="px-[var(--spacing-system-mf)] pb-[var(--spacing-system-mf)] pt-0">
281
+ <div
282
+ ref={innerRef}
283
+ className="border-t border-ods-border px-[var(--spacing-system-mf)] pb-[var(--spacing-system-mf)] pt-[var(--spacing-system-mf)]"
284
+ >
268
285
  <div className="overflow-hidden rounded-md border border-ods-border">
269
286
  {/* No rows while the policies are read: nothing read is drawn, even collapsed. */}
270
287
  {(loading ? [] : category.policies).map(policy => (
@@ -290,9 +307,9 @@ const CategorySection: FC<{
290
307
  * background data refreshes never collapse the user's view. Bulk selections
291
308
  * reset when `editMode` changes.
292
309
  *
293
- * Responsive: below `md` the approval level / dropdowns stack inside the text
294
- * column (per the ODS mobile design); at `md+` they render as fixed-width
295
- * right-hand columns.
310
+ * Responsive (content width): below `content-md` a rule's level stacks under its
311
+ * pattern, and a category has no bulk dropdown (Figma 4365-22405); from
312
+ * `content-md` both are right-hand columns.
296
313
  */
297
314
  /** One shared empty map, so a reset that changes nothing changes no identity. */
298
315
  const NO_BULK_LEVELS: Record<string, ApprovalLevel | undefined> = {};
@@ -40,6 +40,7 @@ export { type PackageSearchSlotProps, SoftwareRowFields, type SoftwareRowFieldsP
40
40
  export {
41
41
  DEFAULT_SOFTWARE_UPDATE_POLICY,
42
42
  DEFAULT_SOFTWARE_UPDATE_SETTINGS,
43
+ defaultSoftwareUpdateSettings,
43
44
  SOFTWARE_UPDATE_COUNTDOWN_OPTIONS,
44
45
  SOFTWARE_UPDATE_DEADLINE_OPTIONS,
45
46
  type SoftwareUpdateMode,
@@ -19,6 +19,21 @@ export interface SoftwareActionCopy {
19
19
  logsHeading: string;
20
20
  }
21
21
 
22
+ /**
23
+ * The run modes say when a device actually gets the run, not only when it is sent. Both forms submit a software bundle
24
+ * (`submitSoftwareBundle`), and the backend delivers it per device:
25
+ * - Now: each device gets an online-dispatch sentinel (`SoftwareBundleService.armOnlineDispatch`), so an online device
26
+ * starts at once and an offline one when it comes online.
27
+ * - Scheduled: the schedule is created with `offlineBehavior` RETRY_ON_RECONNECT and the reconnect window
28
+ * `openframe.rmm.software.bundle.schedule.reconnect-window-seconds` (604800 s, 7 days, in saas-tenant
29
+ * `configs/base/openframe-saas-api.yml`): a device offline at the start time gets the run when it reconnects within
30
+ * that window. Keep "within 7 days" in step with that key.
31
+ */
32
+ const nowDescription = (noun: string) =>
33
+ `The ${noun} starts now on online devices, and on the others when they come online.`;
34
+ const scheduleDescription = (noun: string) =>
35
+ `The ${noun} runs at the scheduled time. Devices offline then get it when they come back online, within 7 days.`;
36
+
22
37
  /** Everything the install and update flows word differently, on every page that shows one. */
23
38
  export const SOFTWARE_ACTION_COPY: Record<SoftwareActionKind, SoftwareActionCopy> = {
24
39
  INSTALL: {
@@ -27,12 +42,8 @@ export const SOFTWARE_ACTION_COPY: Record<SoftwareActionKind, SoftwareActionCopy
27
42
  runLabel: 'Run Installation',
28
43
  scheduleLabel: 'Schedule Install',
29
44
  modes: [
30
- { value: 'now', label: 'Install Now', description: 'The install starts immediately.' },
31
- {
32
- value: 'schedule',
33
- label: 'Schedule Install',
34
- description: 'The install runs automatically at the scheduled time.',
35
- },
45
+ { value: 'now', label: 'Install Now', description: nowDescription('install') },
46
+ { value: 'schedule', label: 'Schedule Install', description: scheduleDescription('install') },
36
47
  ],
37
48
  started: 'Installation started',
38
49
  scheduled: 'Installation scheduled',
@@ -46,12 +57,8 @@ export const SOFTWARE_ACTION_COPY: Record<SoftwareActionKind, SoftwareActionCopy
46
57
  runLabel: 'Run Update',
47
58
  scheduleLabel: 'Schedule Update',
48
59
  modes: [
49
- { value: 'now', label: 'Update Now', description: 'The update starts immediately.' },
50
- {
51
- value: 'schedule',
52
- label: 'Schedule Update',
53
- description: 'The update runs automatically at the scheduled time.',
54
- },
60
+ { value: 'now', label: 'Update Now', description: nowDescription('update') },
61
+ { value: 'schedule', label: 'Schedule Update', description: scheduleDescription('update') },
55
62
  ],
56
63
  started: 'Update started',
57
64
  scheduled: 'Update scheduled',
@@ -10,7 +10,7 @@ import { SOFTWARE_ACTION_COPY, type SoftwareActionKind } from './software-action
10
10
  import { newSoftwareRow, type SoftwareRow } from './software-row';
11
11
  import { type PackageSearchSlotProps, SoftwareRowFields } from './software-row-fields';
12
12
  import {
13
- DEFAULT_SOFTWARE_UPDATE_SETTINGS,
13
+ defaultSoftwareUpdateSettings,
14
14
  SoftwareUpdateSettingsFields,
15
15
  type SoftwareUpdateSettingsValues,
16
16
  } from './update-settings-fields';
@@ -74,6 +74,12 @@ export interface SoftwareActionFormProps<TRef extends string = string> {
74
74
  * settings yet never shows choices that go nowhere; the host that submits `updateSettings` passes true.
75
75
  */
76
76
  showUpdateSettings?: boolean;
77
+ /**
78
+ * "Update Settings" offer Wait & notify and the countdowns, which need the devices to notify the user. Default false:
79
+ * Wait (with an optional deadline that closes the app without warning) or Force Update, opening on Force Update, and
80
+ * no countdown.
81
+ */
82
+ updateUserNotifications?: boolean;
77
83
  /** The "Device Selection" heading over the picker. Default true; a host that titles the picker itself passes false. */
78
84
  showDeviceHeading?: boolean;
79
85
  className?: string;
@@ -104,6 +110,7 @@ export function SoftwareActionForm<TRef extends string = string>({
104
110
  addRows = true,
105
111
  runModeLayout = 'stacked',
106
112
  showUpdateSettings = false,
113
+ updateUserNotifications = false,
107
114
  showDeviceHeading = true,
108
115
  className = 'px-[var(--spacing-system-l)] pb-[var(--spacing-system-l)]',
109
116
  }: SoftwareActionFormProps<TRef>) {
@@ -115,7 +122,7 @@ export function SoftwareActionForm<TRef extends string = string>({
115
122
  const [time, setTime] = useState(initialValues.time ?? '');
116
123
  const [timeReference, setTimeReference] = useState<TRef>(initialValues.timeReference);
117
124
  const [updateSettings, setUpdateSettings] = useState<SoftwareUpdateSettingsValues>(
118
- () => initialValues.updateSettings ?? DEFAULT_SOFTWARE_UPDATE_SETTINGS,
125
+ () => initialValues.updateSettings ?? defaultSoftwareUpdateSettings(updateUserNotifications),
119
126
  );
120
127
  const withUpdateSettings = action === 'UPDATE' && showUpdateSettings;
121
128
 
@@ -215,7 +222,13 @@ export function SoftwareActionForm<TRef extends string = string>({
215
222
  </>
216
223
  )}
217
224
 
218
- {withUpdateSettings && <SoftwareUpdateSettingsFields value={updateSettings} onChange={setUpdateSettings} />}
225
+ {withUpdateSettings && (
226
+ <SoftwareUpdateSettingsFields
227
+ value={updateSettings}
228
+ onChange={setUpdateSettings}
229
+ userNotifications={updateUserNotifications}
230
+ />
231
+ )}
219
232
 
220
233
  {showDeviceHeading && (
221
234
  <h2 className="pt-[var(--spacing-system-l)] text-ods-text-primary text-h2">Device Selection</h2>
@@ -1,8 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { InfoCircleIcon } from '../../icons-v2-generated';
4
3
  import {
5
- Alert,
6
4
  CheckboxBlock,
7
5
  Label,
8
6
  RadioGroupBlock,
@@ -71,24 +69,95 @@ export const DEFAULT_SOFTWARE_UPDATE_SETTINGS: SoftwareUpdateSettingsValues = {
71
69
  autoUpdatePolicy: DEFAULT_SOFTWARE_UPDATE_POLICY,
72
70
  };
73
71
 
74
- const MODE_OPTIONS: RadioGroupBlockOption[] = [
75
- {
72
+ /** Force Update, with a 7-day deadline ready for when Wait is picked: what the form opens with without notifications. */
73
+ const FORCE_UPDATE_POLICY: SoftwareUpdatePolicy = {
74
+ ...DEFAULT_SOFTWARE_UPDATE_POLICY,
75
+ mode: 'FORCE',
76
+ deadlineEnabled: false,
77
+ };
78
+
79
+ /**
80
+ * What the form opens with. With user notifications, the server's own defaults. Without them, Force Update: an app is
81
+ * never closed unannounced a week later, and nobody is promised a prompt that cannot be shown.
82
+ */
83
+ export function defaultSoftwareUpdateSettings(userNotifications: boolean): SoftwareUpdateSettingsValues {
84
+ if (userNotifications) return DEFAULT_SOFTWARE_UPDATE_SETTINGS;
85
+ return { ...DEFAULT_SOFTWARE_UPDATE_SETTINGS, policy: FORCE_UPDATE_POLICY, autoUpdatePolicy: FORCE_UPDATE_POLICY };
86
+ }
87
+
88
+ /**
89
+ * The two modes and their deadline, worded by what the device does. With user notifications the user is asked and
90
+ * warned. Without them the agent shows nothing on the device (no prompt, no countdown on screen; there is no end-user
91
+ * UI anywhere yet), so the copy says only what happens:
92
+ * - Wait (WAIT, no deadline): the agent reports Waiting and installs once the app is closed.
93
+ * - A deadline (WAIT + `deadlineSeconds`): counted from the first time the agent finds the app running; at the deadline
94
+ * the backend sends the update again as FORCE (`armDeadline` / `driveDeadlineForce` in openframe-client-core), and
95
+ * withdraws that if the update finished first.
96
+ * - Force (FORCE): the agent waits `countdownSeconds` (clamped to 60–1800 s), asks the app to close, stops it after a
97
+ * 10 s grace and installs (`run.rs` / `close.rs` in openframe-client). The wait is silent, so it is not offered as a
98
+ * control; the copy states it, since the app is not closed at once.
99
+ */
100
+ interface ModeCopy {
101
+ wait: RadioGroupBlockOption;
102
+ force: RadioGroupBlockOption;
103
+ deadline: { label: string; description: string };
104
+ }
105
+
106
+ /** "5 minutes": the silent wait before a forced close, worded as the countdown options word it. */
107
+ function closeDelay(countdownSeconds: number): string {
108
+ const option = SOFTWARE_UPDATE_COUNTDOWN_OPTIONS.find(candidate => candidate.seconds === countdownSeconds);
109
+ if (option) return option.label;
110
+ const minutes = Math.max(1, Math.round(countdownSeconds / 60));
111
+ return minutes === 1 ? '1 minute' : `${minutes} minutes`;
112
+ }
113
+
114
+ const NOTIFIED_COPY: ModeCopy = {
115
+ wait: {
76
116
  value: 'WAIT',
77
117
  label: 'Wait & notify',
78
118
  description: 'The user gets Update now or Ask Later. If they close the app, it updates then.',
79
119
  },
80
- {
120
+ force: {
81
121
  value: 'FORCE',
82
122
  label: 'Force Update',
83
123
  description: 'The user gets a short countdown, then the app closes and updates.',
84
124
  },
85
- ];
125
+ deadline: {
126
+ label: 'Countdown after the deadline',
127
+ description: 'If the update still isn’t installed by the deadline, the user gets a countdown and then it installs.',
128
+ },
129
+ };
130
+
131
+ function silentCopy(countdownSeconds: number): ModeCopy {
132
+ const delay = closeDelay(countdownSeconds);
133
+ return {
134
+ wait: { value: 'WAIT', label: 'Wait', description: 'The update waits until the user closes the app.' },
135
+ force: {
136
+ value: 'FORCE',
137
+ label: 'Force Update',
138
+ description: `The app is closed without warning ${delay} later, then updated.`,
139
+ },
140
+ deadline: {
141
+ label: 'Close the app after a deadline',
142
+ description: `If the app is still open by the deadline, it is closed without warning ${delay} later, then updated.`,
143
+ },
144
+ };
145
+ }
86
146
 
87
147
  const SAME_SETTINGS_DESCRIPTION: Record<SoftwareUpdateMode, string> = {
88
148
  WAIT: 'Auto-updates follow "If the app is running" above. Uncheck to set them separately.',
89
149
  FORCE: 'Later releases will also be force-updated.',
90
150
  };
91
151
 
152
+ /**
153
+ * "Keep it auto-updated", as the backend runs it (`PackageAutoUpdatePolicyService.turnOn`, `PackageAutoUpdateTrigger`):
154
+ * a run with it on enables a policy per device for the package, and the trigger orders each new catalog version (Brew
155
+ * and winget catalogs) on those devices. A run with it off leaves an earlier policy as it is: only Turn Off Auto-Update
156
+ * (`turnOffSoftwareAutoUpdate`) or the package leaving the device turns one off, so "off" does not mean "stop".
157
+ */
158
+ const AUTO_UPDATE_DESCRIPTION =
159
+ 'After this update, OpenFrame installs each new version from Winget or Brew on the selected devices. Leaving it off does not turn off auto-update that is already on there.';
160
+
92
161
  const groupLabelClass = 'text-h5 text-ods-text-secondary';
93
162
 
94
163
  function SecondsSelect({
@@ -126,17 +195,21 @@ function UpdatePolicyFields({
126
195
  name,
127
196
  label,
128
197
  policy,
198
+ userNotifications,
129
199
  onChange,
130
200
  }: {
131
201
  /** The radio group's name: two policies on one page must not share it. */
132
202
  name: string;
133
203
  label: string;
134
204
  policy: SoftwareUpdatePolicy;
205
+ /** Off, the choices say what the device does with nobody told, and there is no countdown to set. */
206
+ userNotifications: boolean;
135
207
  onChange: (policy: SoftwareUpdatePolicy) => void;
136
208
  }) {
209
+ const copy = userNotifications ? NOTIFIED_COPY : silentCopy(policy.countdownSeconds);
137
210
  const waits = policy.mode === 'WAIT';
138
211
  const deadline = waits && policy.deadlineEnabled;
139
- const countdown = (
212
+ const countdown = userNotifications && (
140
213
  <SecondsSelect
141
214
  label="Countdown"
142
215
  value={policy.countdownSeconds}
@@ -154,7 +227,7 @@ function UpdatePolicyFields({
154
227
  variant="grouped"
155
228
  value={policy.mode}
156
229
  onValueChange={mode => onChange({ ...policy, mode: mode as SoftwareUpdateMode })}
157
- options={MODE_OPTIONS}
230
+ options={[copy.wait, copy.force]}
158
231
  itemClassName="py-[var(--spacing-system-sf)]"
159
232
  />
160
233
  </div>
@@ -164,8 +237,8 @@ function UpdatePolicyFields({
164
237
  id={`${name}-deadline`}
165
238
  checked={policy.deadlineEnabled}
166
239
  onCheckedChange={deadlineEnabled => onChange({ ...policy, deadlineEnabled })}
167
- label="Countdown after the deadline"
168
- description="If the update still isn’t installed by the deadline, the user gets a countdown and then it installs."
240
+ label={copy.deadline.label}
241
+ description={copy.deadline.description}
169
242
  />
170
243
  {deadline && (
171
244
  <div className="grid grid-cols-1 gap-[var(--spacing-system-lf)] content-md:grid-cols-3 content-md:items-start">
@@ -180,7 +253,9 @@ function UpdatePolicyFields({
180
253
  )}
181
254
  </div>
182
255
  ) : (
183
- <div className="grid grid-cols-1 gap-[var(--spacing-system-lf)] content-md:grid-cols-3">{countdown}</div>
256
+ userNotifications && (
257
+ <div className="grid grid-cols-1 gap-[var(--spacing-system-lf)] content-md:grid-cols-3">{countdown}</div>
258
+ )
184
259
  )}
185
260
  </div>
186
261
  );
@@ -189,6 +264,12 @@ function UpdatePolicyFields({
189
264
  export interface SoftwareUpdateSettingsFieldsProps {
190
265
  value: SoftwareUpdateSettingsValues;
191
266
  onChange: (value: SoftwareUpdateSettingsValues) => void;
267
+ /**
268
+ * The devices can notify the user: Wait & notify, its countdown after the deadline and the countdowns. Default false,
269
+ * as today, when the client has no notifications: Wait (until the user closes the app, or a deadline closes it without
270
+ * warning) or Force Update, and no countdown.
271
+ */
272
+ userNotifications?: boolean;
192
273
  }
193
274
 
194
275
  /**
@@ -196,7 +277,11 @@ export interface SoftwareUpdateSettingsFieldsProps {
196
277
  * rules 1034:8173): what happens on a device where the app is running, and
197
278
  * whether later releases are installed the same way.
198
279
  */
199
- export function SoftwareUpdateSettingsFields({ value, onChange }: SoftwareUpdateSettingsFieldsProps) {
280
+ export function SoftwareUpdateSettingsFields({
281
+ value,
282
+ onChange,
283
+ userNotifications = false,
284
+ }: SoftwareUpdateSettingsFieldsProps) {
200
285
  return (
201
286
  <section aria-labelledby="software-update-settings" className="flex flex-col gap-[var(--spacing-system-l)]">
202
287
  <h2 id="software-update-settings" className="pt-[var(--spacing-system-l)] text-ods-text-primary text-h2">
@@ -207,6 +292,7 @@ export function SoftwareUpdateSettingsFields({ value, onChange }: SoftwareUpdate
207
292
  name="updatePolicy"
208
293
  label="If the app is running"
209
294
  policy={value.policy}
295
+ userNotifications={userNotifications}
210
296
  onChange={policy => onChange({ ...value, policy })}
211
297
  />
212
298
 
@@ -218,7 +304,7 @@ export function SoftwareUpdateSettingsFields({ value, onChange }: SoftwareUpdate
218
304
  checked={value.autoUpdate}
219
305
  onCheckedChange={autoUpdate => onChange({ ...value, autoUpdate })}
220
306
  label="Keep it auto-updated"
221
- description="After this update, OpenFrame installs each new version from Winget or Brew on the selected devices. When off, only this update runs."
307
+ description={AUTO_UPDATE_DESCRIPTION}
222
308
  />
223
309
  {value.autoUpdate && (
224
310
  <>
@@ -235,6 +321,7 @@ export function SoftwareUpdateSettingsFields({ value, onChange }: SoftwareUpdate
235
321
  name="autoUpdatePolicy"
236
322
  label="For auto-updates"
237
323
  policy={value.autoUpdatePolicy}
324
+ userNotifications={userNotifications}
238
325
  onChange={autoUpdatePolicy => onChange({ ...value, autoUpdatePolicy })}
239
326
  />
240
327
  </div>
@@ -244,25 +331,10 @@ export function SoftwareUpdateSettingsFields({ value, onChange }: SoftwareUpdate
244
331
  </div>
245
332
  </div>
246
333
 
247
- {/* Core Alert on the card surface, as a note: it comes with "Keep it auto-updated" and goes with it, since the
248
- reinstall is what lets OpenFrame keep a hand-installed app updated, and a one-off update promises no more. The
249
- icon sits in a span so Alert's `[&>svg]` positioning does not apply. */}
250
- {value.autoUpdate && (
251
- <Alert
252
- role="note"
253
- className="flex items-center gap-[var(--spacing-system-m)] rounded-[6px] border-ods-border bg-ods-card p-[var(--spacing-system-m)]"
254
- >
255
- <span className="shrink-0 text-ods-text-secondary">
256
- <InfoCircleIcon size={24} color="currentColor" />
257
- </span>
258
- <div className="flex min-w-0 flex-1 flex-col gap-[var(--spacing-system-xxs)]">
259
- <p className="text-ods-text-primary text-h3">Apps installed by hand get reinstalled</p>
260
- <p className="text-ods-text-secondary text-h6">
261
- On the first update, OpenFrame reinstalls them with Winget or Brew so it can keep them updated.
262
- </p>
263
- </div>
264
- </Alert>
265
- )}
334
+ {/* No note that apps installed by hand get reinstalled on the first update: the agent does not do that. A copy no
335
+ package manager installed is left alone (brew probes it as not installed, winget as "not installed from a
336
+ winget source"), so it is neither updated nor reinstalled. Reinstalling it with a package manager is Adopt
337
+ (`adoptSoftware`), which the backend does not implement yet. */}
266
338
  </section>
267
339
  );
268
340
  }