@puredesktop/puredesktop-ui-bridge 2.1.8 → 2.1.9

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 (75) hide show
  1. package/package.json +35 -5
  2. package/src/agents/mcpServerPresets.test.ts +105 -0
  3. package/src/agents/mcpServerPresets.ts +380 -0
  4. package/src/agents/runtime/index.ts +7 -0
  5. package/src/agents/runtime/mapAgentSnapshot.ts +11 -8
  6. package/src/agents/runtime/pendingAsks.test.ts +117 -0
  7. package/src/agents/runtime/pendingAsks.ts +110 -0
  8. package/src/bridge/agentScopeDefaults.ts +65 -0
  9. package/src/bridge/agentTypes.ts +36 -2
  10. package/src/bridge/agents.d.mts +6 -0
  11. package/src/bridge/agents.mjs +10 -0
  12. package/src/bridge/agents.ts +4 -0
  13. package/src/bridge/credentials.d.mts +62 -0
  14. package/src/bridge/credentials.mjs +53 -0
  15. package/src/bridge/events.d.mts +1 -0
  16. package/src/bridge/events.mjs +1 -0
  17. package/src/bridge/googleAuth.ts +7 -152
  18. package/src/bridge/mcpClient.d.mts +74 -0
  19. package/src/bridge/mcpClient.mjs +79 -0
  20. package/src/bridge/methods.d.mts +22 -0
  21. package/src/bridge/methods.mjs +23 -0
  22. package/src/bridge/notifications.d.mts +12 -0
  23. package/src/bridge/notifications.mjs +36 -0
  24. package/src/bridge/operations.d.mts +44 -0
  25. package/src/bridge/operations.mjs +40 -0
  26. package/src/bridge/react/usePlatformAgentSessionDrive.test.tsx +29 -10
  27. package/src/bridge/react/usePlatformAgentSessionDrive.tsx +10 -81
  28. package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -105
  29. package/src/bridge/react/usePlatformAppSettings.tsx +104 -104
  30. package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -152
  31. package/src/bridge/react/usePlatformJsonStore.tsx +149 -149
  32. package/src/bridge/react/usePlatformOperations.tsx +75 -0
  33. package/src/bridge/storage.d.mts +17 -4
  34. package/src/bridge/storage.mjs +6 -3
  35. package/src/components/agents/AgentComposer.tsx +10 -6
  36. package/src/components/agents/AgentDrawerPanel.test.tsx +2 -205
  37. package/src/components/agents/AgentDrawerPanel.tsx +23 -64
  38. package/src/components/agents/AgentMessageBubble.tsx +59 -33
  39. package/src/components/agents/AgentMessageList.tsx +4 -9
  40. package/src/components/agents/AgentQuestionPromptList.tsx +34 -212
  41. package/src/components/agents/AgentToolApprovalActions.tsx +13 -11
  42. package/src/components/agents/AgentToolPendingList.tsx +6 -6
  43. package/src/components/agents/AgentToolPendingReview.tsx +6 -6
  44. package/src/components/agents/QuestionRequestList.tsx +278 -0
  45. package/src/components/agents/agentPanelStyles.ts +9 -33
  46. package/src/components/agents/agentTypes.ts +25 -43
  47. package/src/components/agents/index.ts +4 -0
  48. package/src/components/assets/asset-library/sidebar/AssetLibraryList.tsx +10 -4
  49. package/src/components/chrome/WorkspaceTabStrip.tsx +4 -3
  50. package/src/components/common/chat/ChatBox.tsx +9 -9
  51. package/src/components/common/chat/ChatModelMenu.tsx +34 -4
  52. package/src/components/common/chat/ChatThread.tsx +23 -7
  53. package/src/components/common/connections/ProviderConnection.test.tsx +208 -0
  54. package/src/components/common/connections/ProviderConnection.tsx +277 -0
  55. package/src/components/common/connections/index.ts +5 -0
  56. package/src/components/common/containers/AppFrame.tsx +26 -19
  57. package/src/components/common/containers/AppHeader.tsx +20 -20
  58. package/src/components/common/documents/DocumentSwitcher.tsx +63 -58
  59. package/src/components/common/dropdown/MenuDropdownItem.tsx +14 -2
  60. package/src/components/common/dropdown/MenuPortal.tsx +3 -1
  61. package/src/components/common/dropdown/menuViewportPosition.test.ts +20 -10
  62. package/src/components/common/dropdown/menuViewportPosition.ts +35 -27
  63. package/src/components/common/overlays/Modal.tsx +13 -2
  64. package/src/components/editor/EditorLinkPromptDialog.tsx +1 -1
  65. package/src/ics/generateIcs.test.ts +153 -0
  66. package/src/ics/generateIcs.ts +197 -0
  67. package/src/index.ts +3 -0
  68. package/src/net/httpRetry.test.ts +117 -0
  69. package/src/net/httpRetry.ts +111 -0
  70. package/src/theme/appAccents.ts +6 -0
  71. package/src/theme/appIdentityCss.mjs +11 -0
  72. package/src/theme/appIdentityCss.ts +11 -0
  73. package/src/theme/themes/dark.ts +3 -1
  74. package/src/theme/themes/light.ts +3 -1
  75. package/src/bridge/googleProviderConfig.ts +0 -73
@@ -613,7 +613,7 @@ const Scrim = styled.div`
613
613
 
614
614
  const SheetHolder = styled.div`
615
615
  position: absolute;
616
- inset: 28px;
616
+ inset: 24px;
617
617
  display: flex;
618
618
  `
619
619
 
@@ -626,7 +626,7 @@ const Sheet = styled.div<{ $variant: 'modal' | 'landing' }>`
626
626
  overflow: hidden;
627
627
  color: ${ink};
628
628
  background: ${elevated};
629
- border-radius: ${({ $variant }) => ($variant === 'modal' ? '10px' : '0')};
629
+ border-radius: ${({ $variant }) => ($variant === 'modal' ? '6px' : '0')};
630
630
  box-shadow: ${({ $variant }) =>
631
631
  $variant === 'modal' ? '0 24px 80px rgba(14, 14, 17, 0.4)' : 'none'};
632
632
  font-family: ${sansStack};
@@ -635,7 +635,7 @@ const Sheet = styled.div<{ $variant: 'modal' | 'landing' }>`
635
635
  const Rail = styled.div`
636
636
  display: flex;
637
637
  flex-direction: column;
638
- padding: 30px 22px;
638
+ padding: 24px 18px;
639
639
  background: ${surface};
640
640
  border-right: 1px solid ${line};
641
641
  `
@@ -670,14 +670,16 @@ const RailItem = styled.button<{ $active?: boolean }>`
670
670
  gap: 11px;
671
671
  width: 100%;
672
672
  margin-bottom: 2px;
673
- padding: 10px 12px;
673
+ padding: 8px 10px;
674
674
  border: 0;
675
675
  border-left: 3px solid
676
676
  ${({ $active }) => ($active ? 'var(--app-acc, #1f9d57)' : 'transparent')};
677
- border-radius: 7px;
677
+ border-radius: ${({ $active }) => ($active ? '0 4px 4px 0' : '4px')};
678
678
  background: ${({ $active }) =>
679
- $active ? 'var(--app-bg, #eef7f1)' : 'transparent'};
680
- color: ${({ $active }) => ($active ? 'var(--app-text, #15703f)' : '#4a4a50')};
679
+ $active
680
+ ? 'color-mix(in srgb, var(--app-bg, #eef7f1) 42%, transparent)'
681
+ : 'transparent'};
682
+ color: ${({ $active }) => ($active ? ink : '#4a4a50')};
681
683
  font: inherit;
682
684
  font-size: 14px;
683
685
  font-weight: ${({ $active }) => ($active ? 600 : 400)};
@@ -686,7 +688,9 @@ const RailItem = styled.button<{ $active?: boolean }>`
686
688
 
687
689
  &:hover {
688
690
  background: ${({ $active }) =>
689
- $active ? 'var(--app-bg, #eef7f1)' : 'rgba(20, 18, 22, 0.05)'};
691
+ $active
692
+ ? 'color-mix(in srgb, var(--app-bg, #eef7f1) 42%, transparent)'
693
+ : 'rgba(20, 18, 22, 0.05)'};
690
694
  }
691
695
  `
692
696
 
@@ -732,7 +736,7 @@ const MainHead = styled.div`
732
736
  align-items: flex-start;
733
737
  justify-content: space-between;
734
738
  gap: 24px;
735
- padding: 28px 40px 20px;
739
+ padding: 24px 32px 18px;
736
740
  border-bottom: 1px solid ${line};
737
741
  `
738
742
 
@@ -773,17 +777,17 @@ const FilterWrap = styled.div`
773
777
 
774
778
  const FilterInput = styled.input`
775
779
  width: 260px;
776
- padding: 11px 14px 11px 38px;
780
+ padding: 9px 12px 9px 38px;
777
781
  border: 1px solid ${line};
778
- border-radius: 8px;
779
- background: ${surface};
782
+ border-radius: 4px;
783
+ background: ${elevated};
780
784
  color: ${ink};
781
785
  font: inherit;
782
786
  font-size: 14px;
783
787
  outline: none;
784
788
 
785
789
  &:focus {
786
- border-color: var(--app-acc, #1f9d57);
790
+ border-color: ${ink};
787
791
  }
788
792
  `
789
793
 
@@ -791,10 +795,10 @@ const CloseButton = styled.button`
791
795
  display: flex;
792
796
  align-items: center;
793
797
  justify-content: center;
794
- width: 38px;
795
- height: 38px;
798
+ width: 34px;
799
+ height: 34px;
796
800
  border: 1px solid ${line};
797
- border-radius: 8px;
801
+ border-radius: 4px;
798
802
  background: ${elevated};
799
803
  color: #6a6a70;
800
804
  cursor: pointer;
@@ -808,25 +812,25 @@ const Body = styled.div`
808
812
  flex: 1;
809
813
  min-height: 0;
810
814
  overflow-y: auto;
811
- padding: 26px 40px 40px;
815
+ padding: 22px 32px 32px;
812
816
  `
813
817
 
814
818
  const CardRow = styled.div`
815
819
  display: grid;
816
820
  grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
817
- gap: 16px;
818
- margin-bottom: 34px;
821
+ gap: 12px;
822
+ margin-bottom: 28px;
819
823
  `
820
824
 
821
825
  const cardBase = `
822
- min-height: 190px;
823
- border-radius: 10px;
826
+ min-height: 172px;
827
+ border-radius: 6px;
824
828
  cursor: pointer;
825
- transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease;
829
+ transition: box-shadow 0.12s ease, border-color 0.12s ease, background 0.12s ease;
826
830
 
827
831
  &:hover {
828
- transform: translateY(-3px);
829
- box-shadow: 0 12px 30px rgba(14, 14, 17, 0.16);
832
+ border-color: color-mix(in srgb, ${ink} 24%, ${line});
833
+ box-shadow: 0 8px 18px rgba(14, 14, 17, 0.1);
830
834
  }
831
835
  `
832
836
 
@@ -836,9 +840,9 @@ const NewCard = styled.button`
836
840
  flex-direction: column;
837
841
  align-items: center;
838
842
  justify-content: center;
839
- gap: 12px;
840
- border: 1.5px dashed var(--app-acc, #1f9d57);
841
- background: var(--app-bg, #eef7f1);
843
+ gap: 10px;
844
+ border: 1px solid ${line};
845
+ background: ${elevated};
842
846
  font: inherit;
843
847
  `
844
848
 
@@ -846,24 +850,23 @@ const NewIcon = styled.span`
846
850
  display: flex;
847
851
  align-items: center;
848
852
  justify-content: center;
849
- width: 46px;
850
- height: 46px;
851
- border-radius: 11px;
852
- background: var(--app-block, #235240);
853
- color: #fff;
853
+ width: 30px;
854
+ height: 30px;
855
+ border-radius: 4px;
856
+ background: ${elevated};
857
+ color: var(--app-acc, #1f9d57);
854
858
  `
855
859
 
856
860
  const NewLabel = styled.span`
857
861
  font-size: 15px;
858
862
  font-weight: 600;
859
- color: var(--app-text, #15703f);
863
+ color: ${ink};
860
864
  `
861
865
 
862
866
  const NewHint = styled.span`
863
867
  font-family: ${monoStack};
864
868
  font-size: 10.5px;
865
- color: var(--app-text, #15703f);
866
- opacity: 0.7;
869
+ color: ${faint};
867
870
  `
868
871
 
869
872
  const PreviewCard = styled.button`
@@ -891,7 +894,7 @@ const PreviewMeta = styled.div`
891
894
  align-items: center;
892
895
  justify-content: space-between;
893
896
  gap: 8px;
894
- padding: 13px 15px;
897
+ padding: 11px 13px;
895
898
  `
896
899
 
897
900
  const PreviewName = styled.div<{ $serif?: boolean }>`
@@ -946,7 +949,7 @@ const SortButton = styled.button<{ $active?: boolean }>`
946
949
  const ListBox = styled.div`
947
950
  overflow: hidden;
948
951
  border: 1px solid ${line};
949
- border-radius: 10px;
952
+ border-radius: 6px;
950
953
  `
951
954
 
952
955
  const Row = styled.button<{ $selected?: boolean }>`
@@ -955,11 +958,10 @@ const Row = styled.button<{ $selected?: boolean }>`
955
958
  align-items: center;
956
959
  gap: 14px;
957
960
  width: 100%;
958
- padding: 13px 18px;
961
+ padding: 11px 14px;
959
962
  border: 0;
960
963
  border-bottom: 1px solid ${line};
961
- background: ${({ $selected }) =>
962
- $selected ? 'var(--app-bg, #eef7f1)' : 'transparent'};
964
+ background: ${({ $selected }) => ($selected ? surface : 'transparent')};
963
965
  font: inherit;
964
966
  text-align: left;
965
967
  cursor: pointer;
@@ -975,9 +977,9 @@ const RowIcon = styled.span`
975
977
  justify-content: center;
976
978
  width: 34px;
977
979
  height: 34px;
978
- border-radius: 8px;
979
- background: var(--app-bg, #eef7f1);
980
- color: var(--app-acc, #1f9d57);
980
+ border-radius: 4px;
981
+ background: ${surface};
982
+ color: ${faint};
981
983
  `
982
984
 
983
985
  const RowMain = styled.div`
@@ -1008,19 +1010,22 @@ const RowWhen = styled.div`
1008
1010
 
1009
1011
  const RowBadge = styled.span<{ $draft?: boolean }>`
1010
1012
  justify-self: end;
1011
- padding: 3px 9px;
1012
- border-radius: 999px;
1013
+ padding: 2px 8px;
1014
+ border: 1px solid ${line};
1015
+ border-radius: 4px;
1013
1016
  font-family: ${monoStack};
1014
1017
  font-size: 10px;
1015
1018
  letter-spacing: 0.06em;
1016
1019
  text-transform: uppercase;
1017
- color: ${({ $draft }) => ($draft ? '#8a5a12' : '#6a6a70')};
1018
- background: ${({ $draft }) => ($draft ? '#f6ecd6' : 'rgba(20,18,22,0.05)')};
1020
+ color: ${faint};
1021
+ background: ${elevated};
1019
1022
  `
1020
1023
 
1021
1024
  const EmptyNote = styled.p`
1022
- margin: 0;
1023
- padding: 34px 20px;
1025
+ margin: 8px;
1026
+ padding: 24px 18px;
1027
+ border: 1px solid ${line};
1028
+ border-radius: 4px;
1024
1029
  text-align: center;
1025
1030
  color: ${faint};
1026
1031
  font-size: 13.5px;
@@ -1030,7 +1035,7 @@ const FooterBar = styled.div`
1030
1035
  display: flex;
1031
1036
  align-items: center;
1032
1037
  justify-content: space-between;
1033
- padding: 16px 40px;
1038
+ padding: 14px 32px;
1034
1039
  border-top: 1px solid ${line};
1035
1040
  background: ${surface};
1036
1041
  `
@@ -1047,9 +1052,9 @@ const FooterActions = styled.div`
1047
1052
  `
1048
1053
 
1049
1054
  const SecondaryButton = styled.button`
1050
- padding: 9px 16px;
1055
+ padding: 8px 14px;
1051
1056
  border: 1px solid ${line};
1052
- border-radius: 8px;
1057
+ border-radius: 4px;
1053
1058
  background: ${elevated};
1054
1059
  color: #4a4a50;
1055
1060
  font: inherit;
@@ -1063,9 +1068,9 @@ const SecondaryButton = styled.button`
1063
1068
  `
1064
1069
 
1065
1070
  const PrimaryButton = styled.button`
1066
- padding: 9px 18px;
1071
+ padding: 8px 16px;
1067
1072
  border: 0;
1068
- border-radius: 8px;
1073
+ border-radius: 4px;
1069
1074
  background: var(--app-block, #235240);
1070
1075
  color: #fff;
1071
1076
  font: inherit;
@@ -1123,9 +1128,9 @@ const ThumbPills = styled.div`
1123
1128
 
1124
1129
  const ThumbPill = styled.span<{ $accent?: boolean }>`
1125
1130
  height: 5px;
1126
- border-radius: 999px;
1131
+ border-radius: 2px;
1127
1132
  background: ${({ $accent }) =>
1128
- $accent ? 'var(--app-acc, #1f9d57)' : 'rgba(20,18,22,0.15)'};
1133
+ $accent ? 'rgba(20,18,22,0.24)' : 'rgba(20,18,22,0.15)'};
1129
1134
  `
1130
1135
 
1131
1136
  const ThumbGrid = styled.div`
@@ -1142,7 +1147,7 @@ const ThumbCell = styled.div<{ $accent?: boolean }>`
1142
1147
  background: ${elevated};
1143
1148
  border: 1px solid ${line};
1144
1149
  border-left: ${({ $accent }) =>
1145
- $accent ? '2px solid var(--app-acc, #1f9d57)' : `1px solid ${line}`};
1150
+ $accent ? '2px solid rgba(20,18,22,0.24)' : `1px solid ${line}`};
1146
1151
  `
1147
1152
 
1148
1153
  const ThumbCols = styled.div`
@@ -1170,7 +1175,7 @@ const ThumbBlock = styled.div<{ $accent?: boolean }>`
1170
1175
  background: ${elevated};
1171
1176
  border: 1px solid ${line};
1172
1177
  border-left: ${({ $accent }) =>
1173
- $accent ? '2px solid var(--app-acc, #1f9d57)' : `1px solid ${line}`};
1178
+ $accent ? '2px solid rgba(20,18,22,0.24)' : `1px solid ${line}`};
1174
1179
  `
1175
1180
 
1176
1181
  //#endregion
@@ -83,12 +83,24 @@ export function MenuDropdownItem({
83
83
  $hasIcon={Boolean(item.icon)}
84
84
  $tone={item.tone}
85
85
  onPointerDown={event => {
86
- if (event.pointerType === 'mouse' || event.pointerType === 'touch') {
86
+ // Select on pointerdown for pointing devices (fires before the menu
87
+ // closes) and preventDefault so the item doesn't steal focus.
88
+ if (
89
+ event.pointerType === 'mouse' ||
90
+ event.pointerType === 'touch' ||
91
+ event.pointerType === 'pen'
92
+ ) {
87
93
  event.preventDefault()
88
94
  selectItem()
89
95
  }
90
96
  }}
91
- onClick={selectItem}
97
+ onClick={event => {
98
+ // A pointer interaction already selected on pointerdown above; the
99
+ // browser still fires a follow-up click, which would run the action a
100
+ // SECOND time (e.g. inserting two equations). Only handle genuine
101
+ // keyboard activation here — Enter/Space produce a click with detail 0.
102
+ if (event.detail === 0) selectItem()
103
+ }}
92
104
  >
93
105
  {item.icon ? (
94
106
  <span className="menu-item-icon" aria-hidden="true">
@@ -137,9 +137,11 @@ export function MenuPortal({
137
137
 
138
138
  const surfaceStyle: CSSProperties = layout
139
139
  ? {
140
- top: layout.top,
141
140
  left: layout.left,
142
141
  maxHeight: layout.maxHeight,
142
+ // Above-opening menus are pinned by `bottom` so they grow upward.
143
+ ...(layout.top !== undefined ? { top: layout.top } : {}),
144
+ ...(layout.bottom !== undefined ? { bottom: layout.bottom } : {}),
143
145
  ...(layout.width !== undefined ? { width: layout.width } : {}),
144
146
  ...style,
145
147
  }
@@ -71,7 +71,8 @@ describe('resolveMenuViewportLayout', () => {
71
71
  expect(layout.width).toBeGreaterThanOrEqual(120)
72
72
  })
73
73
 
74
- it('opens above the anchor when there is more space above', () => {
74
+ it('bottom-anchors a below-start menu that flips above at the viewport bottom', () => {
75
+ const viewportHeight = 760
75
76
  const layout = resolveMenuViewportLayout({
76
77
  anchorRect: {
77
78
  top: 720,
@@ -83,16 +84,23 @@ describe('resolveMenuViewportLayout', () => {
83
84
  preferredTop: 752,
84
85
  preferredLeft: 120,
85
86
  placement: 'below-start',
87
+ // Menu measured collapsed (pre-expansion) — must not drive the height.
86
88
  menuWidth: 200,
87
- menuHeight: 220,
89
+ menuHeight: 8,
88
90
  viewportWidth: 1000,
89
- viewportHeight: 760,
91
+ viewportHeight,
90
92
  })
91
93
 
92
- expect(layout.top).toBeLessThan(720)
94
+ // Pinned by bottom (grows upward), not top; its bottom edge sits above the
95
+ // anchor and it gets the real space above (not the ~0 measured height).
96
+ expect(layout.top).toBeUndefined()
97
+ expect(layout.bottom).toBeGreaterThan(0)
98
+ expect(viewportHeight - (layout.bottom ?? 0)).toBeLessThanOrEqual(720)
99
+ expect(layout.maxHeight).toBeGreaterThan(100)
93
100
  })
94
101
 
95
- it('opens above-start menus upward from the anchor', () => {
102
+ it('bottom-anchors above-start menus so they grow upward from the anchor', () => {
103
+ const viewportHeight = 760
96
104
  const layout = resolveMenuViewportLayout({
97
105
  anchorRect: {
98
106
  top: 720,
@@ -105,13 +113,14 @@ describe('resolveMenuViewportLayout', () => {
105
113
  preferredLeft: 120,
106
114
  placement: 'above-start',
107
115
  menuWidth: 200,
108
- menuHeight: 180,
116
+ menuHeight: 8,
109
117
  viewportWidth: 1000,
110
- viewportHeight: 760,
118
+ viewportHeight,
111
119
  })
112
120
 
113
- expect(layout.top).toBeLessThan(720)
114
- expect(layout.top + layout.maxHeight).toBeLessThanOrEqual(720)
121
+ expect(layout.top).toBeUndefined()
122
+ expect(viewportHeight - (layout.bottom ?? 0)).toBeLessThanOrEqual(720)
123
+ expect(layout.maxHeight).toBeGreaterThan(100)
115
124
  })
116
125
 
117
126
  it('limits menu height to the remaining viewport space', () => {
@@ -134,6 +143,7 @@ describe('resolveMenuViewportLayout', () => {
134
143
  })
135
144
 
136
145
  expect(layout.maxHeight).toBeLessThanOrEqual(280)
137
- expect(layout.top + layout.maxHeight).toBeLessThanOrEqual(300)
146
+ expect(layout.top).toBeDefined()
147
+ expect((layout.top ?? 0) + layout.maxHeight).toBeLessThanOrEqual(300)
138
148
  })
139
149
  })
@@ -10,7 +10,14 @@ export const MENU_DEFAULT_MAX_HEIGHT_PX = 320
10
10
  export type MenuWidthMode = 'intrinsic' | 'anchor'
11
11
 
12
12
  export interface ResolvedMenuLayout {
13
- top: number
13
+ /**
14
+ * Exactly one of `top`/`bottom` is set. Above-opening menus pin by `bottom`
15
+ * so they grow upward — deriving `top`/height from the menu's own measurement
16
+ * is circular (it's measured collapsed, before the open transition expands
17
+ * it, which clipped bottom-anchored menus to ~0px).
18
+ */
19
+ top?: number
20
+ bottom?: number
14
21
  left: number
15
22
  maxHeight: number
16
23
  /** Set only for `anchor` width mode. */
@@ -78,45 +85,46 @@ export function resolveMenuViewportLayout({
78
85
  Math.max(margin, viewportWidth - layoutWidth - margin),
79
86
  )
80
87
 
81
- let top = preferredTop
88
+ const spaceAbove = Math.max(0, anchorRect.top - gap - margin)
82
89
 
90
+ let opensAbove: boolean
83
91
  if (placement === 'above-start') {
84
- top = anchorRect.top - gap - menuHeight
85
- const spaceAbove = Math.max(0, anchorRect.top - gap - margin)
86
92
  const spaceBelow = Math.max(
87
93
  0,
88
94
  viewportHeight - anchorRect.bottom - gap - margin,
89
95
  )
90
- if (menuHeight > spaceAbove && spaceBelow > spaceAbove) {
91
- top = anchorRect.bottom + gap
92
- }
93
- } else if (
94
- placement === 'below-start' ||
95
- placement === 'below-menubar-start' ||
96
- placement === 'at-point'
97
- ) {
96
+ // Prefer above; flip below only if it can't fit above but fits better below.
97
+ opensAbove = !(menuHeight > spaceAbove && spaceBelow > spaceAbove)
98
+ } else {
99
+ // below-start / below-menubar-start / at-point: prefer below, flip above
100
+ // when there's no room below but more above.
98
101
  const opensBelow = preferredTop >= anchorRect.bottom - 0.5
99
- if (opensBelow) {
100
- const spaceBelow = Math.max(0, viewportHeight - preferredTop - margin)
101
- const spaceAbove = Math.max(0, anchorRect.top - gap - margin)
102
- if (menuHeight > spaceBelow && spaceAbove > spaceBelow) {
103
- top = anchorRect.top - gap - menuHeight
104
- }
105
- }
102
+ const spaceBelow = Math.max(0, viewportHeight - preferredTop - margin)
103
+ opensAbove =
104
+ opensBelow && menuHeight > spaceBelow && spaceAbove > spaceBelow
106
105
  }
107
106
 
108
- top = clamp(top, margin, Math.max(margin, viewportHeight - margin))
109
-
110
- const opensAboveAnchor = top < anchorRect.top
111
- const availableHeight = opensAboveAnchor
112
- ? Math.max(0, anchorRect.top - gap - top)
113
- : Math.max(0, viewportHeight - top - margin)
114
- const resolvedMaxHeight = Math.min(maxHeight, availableHeight)
107
+ if (opensAbove) {
108
+ // Pin the panel's bottom just above the anchor so it grows UPWARD; its
109
+ // pre-expansion measured height can't misplace or clip it.
110
+ const bottom = Math.max(margin, viewportHeight - (anchorRect.top - gap))
111
+ return {
112
+ bottom,
113
+ left,
114
+ maxHeight: Math.min(maxHeight, spaceAbove),
115
+ ...(widthMode === 'anchor' ? { width: layoutWidth } : {}),
116
+ }
117
+ }
115
118
 
119
+ const top = clamp(
120
+ placement === 'above-start' ? anchorRect.bottom + gap : preferredTop,
121
+ margin,
122
+ Math.max(margin, viewportHeight - margin),
123
+ )
116
124
  return {
117
125
  top,
118
126
  left,
119
- maxHeight: resolvedMaxHeight,
127
+ maxHeight: Math.min(maxHeight, Math.max(0, viewportHeight - top - margin)),
120
128
  ...(widthMode === 'anchor' ? { width: layoutWidth } : {}),
121
129
  }
122
130
  }
@@ -15,7 +15,7 @@ import { zi } from '../../../theme/themeVars.js'
15
15
  import { PlatformIcon } from '../../chrome/PlatformIcon.js'
16
16
  import { ModalChromeHeader } from './ModalChromeHeader.js'
17
17
 
18
- export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen'
18
+ export type ModalSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen'
19
19
 
20
20
  export interface ModalProps {
21
21
  open: boolean
@@ -42,6 +42,7 @@ const FOCUSABLE_SELECTOR = [
42
42
  ].join(',')
43
43
 
44
44
  const sizeWidths = {
45
+ xs: 'min(420px, calc(100vw - var(--platform-spacing-xl)))',
45
46
  sm: '42dvw',
46
47
  md: '50dvw',
47
48
  lg: '68dvw',
@@ -122,6 +123,9 @@ const StyledDialog = styled.div<{ $size: ModalSize; $chrome: boolean }>`
122
123
  }};
123
124
  min-height: ${({ $size, $chrome }) => {
124
125
  if ($size === 'fullscreen') return '100%'
126
+ // Compact dialogs (confirmations, single-field prompts) hug their content —
127
+ // no forced aspect-ratio min-height that leaves a tall, empty box.
128
+ if ($size === 'xs') return 'auto'
125
129
  if ($chrome && $size === 'xl') {
126
130
  return 'min(460px, calc(100vh - var(--platform-spacing-xl)))'
127
131
  }
@@ -314,7 +318,14 @@ export function Modal({
314
318
  title={title}
315
319
  titleId={titleId}
316
320
  onClose={onClose}
317
- onMaximize={() => setMaximized(current => !current)}
321
+ // A modal that opens fullscreen has nothing to maximize — hide the
322
+ // no-op toggle. (Gate on the requested size, not the computed one,
323
+ // so a modal maximized via this button keeps its Restore control.)
324
+ onMaximize={
325
+ size === 'fullscreen'
326
+ ? undefined
327
+ : () => setMaximized(current => !current)
328
+ }
318
329
  maximized={maximized}
319
330
  trailing={chromeTrailing}
320
331
  />
@@ -19,7 +19,7 @@ export function EditorLinkPromptDialog({
19
19
  }
20
20
 
21
21
  return (
22
- <Modal open={open} onClose={onCancel} title="Insert link" size="md">
22
+ <Modal open={open} onClose={onCancel} title="Insert link" size="xs">
23
23
  <StyledBody>
24
24
  <StyledLinkInput
25
25
  autoFocus