Skip to content

Commit 37553aa

Browse files
committed
chore: merge upstream/master and resolve conflicts
2 parents 5916cdd + 31bcc19 commit 37553aa

25 files changed

Lines changed: 568 additions & 110 deletions

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ on:
88
# Run every 6 days to help us stay on our toes
99
- cron: '0 0 */6 * *'
1010

11+
env:
12+
# Raise Node's V8 old-space limit so `vite build` doesn't OOM.
13+
# The bundle sits near the default ~2 GB cap on every platform and
14+
# intermittently crashes (seen most often on macos-15 arm64).
15+
NODE_OPTIONS: --max-old-space-size=6144
16+
1117
jobs:
1218
test:
1319
name: Test

.github/workflows/claude-pr-review.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,9 @@ jobs:
4949
REPO: ${{ github.repository }}
5050
with:
5151
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
52+
model: "claude-opus-4-6"
5253
max_turns: "40"
53-
allowed_tools: "Bash(gh:*),Bash(jq:*),Bash(wc:*),Bash(cat:*),Bash(head:*),Bash(tail:*),View,GlobTool,GrepTool,BatchTool"
54+
allowed_tools: "Bash(gh:*),Bash(jq:*),Bash(wc:*),Bash(cat:*),Bash(head:*),Bash(tail:*),View,GlobTool,GrepTool,BatchTool,Write"
5455
system_prompt: |
5556
You are an automated code review agent running inside a GitHub Actions workflow for the `bluerobotics/cockpit` repository.
5657

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"jest-diff": "^29.7.0",
6767
"ky": "^1.2.0",
6868
"leaflet": "1.9.3",
69+
"leaflet-edgebuffer": "^1.0.7",
6970
"leaflet.offline": "^3.1.0",
7071
"localforage": "^1.10.0",
7172
"mathjs": "^13.0.3",

src/components/CameraReplacementDialog.vue

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,8 +359,17 @@ watch(
359359
[stabilizationDone, orphanedWidgetStreams, unusedAvailableStreams],
360360
() => {
361361
if (!stabilizationDone.value) return
362+
363+
const hasOrphans = orphanedWidgetStreams.value.length > 0
364+
const hasReplacements = unusedAvailableStreams.value.length > 0
365+
366+
if (!hasOrphans || !hasReplacements) {
367+
if (showDialog.value) showDialog.value = false
368+
dialogTriggered.value = false
369+
return
370+
}
371+
362372
if (dialogTriggered.value) return
363-
if (orphanedWidgetStreams.value.length === 0 || unusedAvailableStreams.value.length === 0) return
364373
365374
dialogTriggered.value = true
366375
initReplacementMap()

src/components/ExternalFeaturesDiscoveryModal.vue

Lines changed: 162 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
<template>
2-
<GlassModal :is-visible="isVisible" position="center">
2+
<GlassModal :is-visible="isVisible" position="center" no-close-on-outside-click @outside-click="requestCloseModal">
33
<div class="features-modal p-4 max-w-[95vw]">
44
<div class="flex justify-center items-center mb-2">
55
<h2 class="text-xl font-semibold">{{ $t('components.ExternalFeaturesDiscoveryModal.title') }}</h2>
66
</div>
77
<div class="fixed top-1 right-1">
8-
<v-btn icon="mdi-close" size="small" variant="text" class="text-lg" @click="closeModal"></v-btn>
8+
<v-btn icon="mdi-close" size="small" variant="text" class="text-lg" @click="requestCloseModal"></v-btn>
99
</div>
1010

1111
<v-tabs v-model="activeTab" class="mb-4">
12-
<v-tab value="actions">{{ $t('components.ExternalFeaturesDiscoveryModal.actionsTab') }}</v-tab>
13-
<v-tab value="joystick-suggestions">{{
14-
$t('components.ExternalFeaturesDiscoveryModal.joystickMappingsTab')
15-
}}</v-tab>
12+
<v-tab value="actions" :class="{ 'tab-blink': activeTab !== 'actions' && hasPendingActions }">
13+
{{ $t('components.ExternalFeaturesDiscoveryModal.actionsTab') }}
14+
</v-tab>
15+
<v-tab
16+
value="joystick-suggestions"
17+
:class="{ 'tab-blink': activeTab !== 'joystick-suggestions' && hasPendingJoystickSuggestions }"
18+
>
19+
{{ $t('components.ExternalFeaturesDiscoveryModal.joystickMappingsTab') }}
20+
</v-tab>
1621
</v-tabs>
1722

1823
<v-tabs-window v-model="activeTab">
@@ -29,6 +34,11 @@
2934
</p>
3035
</div>
3136

37+
<div v-else-if="filteredActions.length === 0" class="text-center py-6">
38+
<v-icon size="40" class="mb-2 opacity-50">mdi-check-circle-outline</v-icon>
39+
<p class="opacity-70 max-w-[70%] mx-auto">No new actions — all extension actions have been reviewed.</p>
40+
</div>
41+
3242
<!-- New Actions -->
3343
<div v-if="filteredActions.length > 0" class="mb-4">
3444
<div class="flex items-center gap-2 mb-2">
@@ -232,6 +242,13 @@
232242
</div>
233243

234244
<div v-else class="actions-container">
245+
<div v-if="filteredJoystickSuggestionsByExtension.length === 0" class="text-center py-6">
246+
<v-icon size="40" class="mb-2 opacity-50">mdi-check-circle-outline</v-icon>
247+
<p class="opacity-70 max-w-[70%] mx-auto">
248+
No new suggestions — all joystick mapping suggestions have been reviewed.
249+
</p>
250+
</div>
251+
235252
<!-- New Suggestions Section -->
236253
<div v-if="filteredJoystickSuggestionsByExtension.length > 0" class="mb-8">
237254
<div class="flex items-center gap-2 mb-4">
@@ -763,6 +780,70 @@
763780
</v-card-actions>
764781
</v-card>
765782
</v-dialog>
783+
784+
<!-- Close Confirmation Dialog -->
785+
<v-dialog v-model="closeConfirmationDialog" max-width="500px">
786+
<v-card class="rounded-lg" :style="interfaceStore.globalGlassMenuStyles">
787+
<v-card-title class="text-center pt-4 pb-0">
788+
<div class="flex items-center justify-center gap-2">
789+
<v-icon color="warning" size="24">mdi-alert</v-icon>
790+
<h2 class="text-xl font-semibold">Pending Extension Features</h2>
791+
</div>
792+
</v-card-title>
793+
<v-btn
794+
icon="mdi-close"
795+
size="small"
796+
variant="text"
797+
class="absolute top-2 right-2 text-lg"
798+
@click="closeConfirmationDialog = false"
799+
></v-btn>
800+
801+
<v-card-text class="px-6 pb-4">
802+
<p class="text-center text-sm text-gray-300 mb-4">
803+
There are still extension features pending your decision. Please accept or ignore each suggestion from
804+
your BlueOS extensions. Otherwise, this dialog will open automatically again the next time you start
805+
Cockpit.
806+
</p>
807+
808+
<div class="max-h-[260px] overflow-y-auto pr-1 space-y-3">
809+
<div v-if="filteredActions.length > 0">
810+
<div class="flex items-center gap-2 mb-1">
811+
<v-icon size="16">mdi-lightning-bolt-outline</v-icon>
812+
<h3 class="text-sm font-semibold">Pending actions ({{ filteredActions.length }})</h3>
813+
</div>
814+
<ul class="list-disc list-inside text-xs text-gray-300 space-y-0.5">
815+
<li v-for="action in filteredActions" :key="action.id">
816+
{{ action.name }} <span class="opacity-60">— from {{ action.extensionName }}</span>
817+
</li>
818+
</ul>
819+
</div>
820+
821+
<div v-if="pendingJoystickSuggestions.length > 0">
822+
<div class="flex items-center gap-2 mb-1">
823+
<v-icon size="16">mdi-gamepad-variant-outline</v-icon>
824+
<h3 class="text-sm font-semibold">
825+
Pending joystick mappings ({{ pendingJoystickSuggestions.length }})
826+
</h3>
827+
</div>
828+
<ul class="list-disc list-inside text-xs text-gray-300 space-y-0.5">
829+
<li v-for="item in pendingJoystickSuggestions" :key="item.id">
830+
{{ item.actionName }} <span class="opacity-60">— from {{ item.extensionName }}</span>
831+
</li>
832+
</ul>
833+
</div>
834+
</div>
835+
</v-card-text>
836+
837+
<div class="flex justify-center w-full px-6 pb-2">
838+
<v-divider style="border-color: #ffffff14"></v-divider>
839+
</div>
840+
841+
<v-card-actions class="px-6 pb-4 justify-space-between">
842+
<v-btn variant="text" @click="confirmCloseModal">Close anyway</v-btn>
843+
<v-btn @click="closeConfirmationDialog = false">Keep reviewing</v-btn>
844+
</v-card-actions>
845+
</v-card>
846+
</v-dialog>
766847
</div>
767848
</GlassModal>
768849
</template>
@@ -1166,11 +1247,35 @@ const ignoredJoystickSuggestionsByExtension = computed(() => {
11661247
.filter((ext) => ext.suggestionGroups.length > 0)
11671248
})
11681249
1250+
/**
1251+
* Whether there are new actions pending user action
1252+
*/
1253+
const hasPendingActions = computed(() => filteredActions.value.length > 0)
1254+
1255+
/**
1256+
* Whether there are new joystick suggestions pending user action
1257+
*/
1258+
const hasPendingJoystickSuggestions = computed(() => filteredJoystickSuggestionsByExtension.value.length > 0)
1259+
11691260
/**
11701261
* Whether there are new extension features that still need user action
11711262
*/
11721263
const hasPendingBlueOSFeatures = computed(() => {
1173-
return filteredActions.value.length > 0 || filteredJoystickSuggestionsByExtension.value.length > 0
1264+
return hasPendingActions.value || hasPendingJoystickSuggestions.value
1265+
})
1266+
1267+
/**
1268+
* Flat list of pending joystick suggestions with their extension names
1269+
*/
1270+
const pendingJoystickSuggestions = computed((): JoystickSuggestionWithExtensionName[] => {
1271+
return filteredJoystickSuggestionsByExtension.value.flatMap((ext) =>
1272+
ext.suggestionGroups.flatMap((group) =>
1273+
group.buttonMappingSuggestions.map((suggestion) => ({
1274+
...suggestion,
1275+
extensionName: ext.extensionName,
1276+
}))
1277+
)
1278+
)
11741279
})
11751280
11761281
/**
@@ -1508,6 +1613,11 @@ const restoreIgnoredSuggestion = (suggestion: JoystickMapSuggestion): void => {
15081613
})
15091614
}
15101615
1616+
/**
1617+
* Controls visibility of the close confirmation dialog
1618+
*/
1619+
const closeConfirmationDialog = ref(false)
1620+
15111621
/**
15121622
* Close the modal
15131623
*/
@@ -1516,6 +1626,25 @@ const closeModal = (): void => {
15161626
emit('close')
15171627
}
15181628
1629+
/**
1630+
* Request to close the modal. If there are still pending items to decide upon, ask the user to confirm first.
1631+
*/
1632+
const requestCloseModal = (): void => {
1633+
if (hasPendingBlueOSFeatures.value) {
1634+
closeConfirmationDialog.value = true
1635+
return
1636+
}
1637+
closeModal()
1638+
}
1639+
1640+
/**
1641+
* Confirm closing the modal from the confirmation dialog
1642+
*/
1643+
const confirmCloseModal = (): void => {
1644+
closeConfirmationDialog.value = false
1645+
closeModal()
1646+
}
1647+
15191648
/**
15201649
* Check for available actions from BlueOS.
15211650
*/
@@ -1594,6 +1723,32 @@ watch(activeTab, () => {
15941723
transition: width 0.2s ease;
15951724
}
15961725
1726+
.tab-blink {
1727+
position: relative;
1728+
}
1729+
1730+
.tab-blink::before {
1731+
content: '';
1732+
position: absolute;
1733+
inset: 0;
1734+
border-top-left-radius: 6px;
1735+
border-top-right-radius: 6px;
1736+
background-color: #ffffff22;
1737+
animation: tab-blink 1.2s ease-in-out infinite;
1738+
pointer-events: none;
1739+
z-index: 0;
1740+
}
1741+
1742+
@keyframes tab-blink {
1743+
0%,
1744+
100% {
1745+
opacity: 0;
1746+
}
1747+
50% {
1748+
opacity: 1;
1749+
}
1750+
}
1751+
15971752
.features-modal:has(.v-expansion-panels) {
15981753
width: 760px;
15991754
}

src/components/GlassModal.vue

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ const props = defineProps<{
4141
* If true, modal will not close by pressing 'esc' or by an outside click.
4242
*/
4343
isPersistent?: boolean
44+
/**
45+
* If true, the modal will not emit `outside-click` when the user clicks outside of it.
46+
* Useful when the modal hosts Vuetify dialogs that are teleported to the document body,
47+
* whose clicks would otherwise be interpreted as outside clicks of the modal.
48+
*/
49+
noCloseOnOutsideClick?: boolean
4450
/**
4551
* The overflow property of the modal.
4652
*/
@@ -177,7 +183,7 @@ const closeModal = (): void => {
177183
}
178184
179185
onClickOutside(modal, () => {
180-
if (!isPersistent.value) {
186+
if (!isPersistent.value && !props.noCloseOnOutsideClick) {
181187
closeModal()
182188
}
183189
if (!isAlwaysOnTop.value) {

src/components/WidgetHugger.vue

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ import { useI18n } from 'vue-i18n'
7575
import { constrain, round } from '@/libs/utils'
7676
import { useDevelopmentStore } from '@/stores/development'
7777
import { useWidgetManagerStore } from '@/stores/widgetManager'
78-
import type { Point2D } from '@/types/general'
78+
import type { Point2D, SizeRect2D } from '@/types/general'
7979
import { type Widget, isWidgetConfigurable, widgetHasOwnContextMenu, WidgetType } from '@/types/widgets'
8080
8181
import ContextMenu from './ContextMenu.vue'
@@ -225,22 +225,42 @@ const handleResizeStart = (event: MouseEvent): void => {
225225
event.preventDefault()
226226
}
227227
228+
/**
229+
* Clamps a desired position to a valid range that keeps the widget within the viewport.
230+
* When the widget is taller than the visible area between bars, it cannot fit between them,
231+
* so the clamp switches to [0, 1 - size.height] to avoid snapping past the viewport (issue #2608).
232+
* @param {Point2D} desiredPos - The candidate position to clamp
233+
* @param {SizeRect2D} widgetSize - The widget's size used for bounds calculation
234+
* @returns {Point2D} The position clamped to the valid range
235+
*/
236+
const clampPositionToValidArea = (desiredPos: Point2D, widgetSize: SizeRect2D): Point2D => {
237+
const topBarNormalized = widgetStore.currentTopBarHeightPixels / windowHeight.value
238+
const bottomBarNormalized = widgetStore.currentBottomBarHeightPixels / windowHeight.value
239+
const visibleAreaHeight = 1 - topBarNormalized - bottomBarNormalized
240+
241+
const widgetTallerThanVisibleArea = widgetSize.height >= visibleAreaHeight
242+
const minY = widgetTallerThanVisibleArea ? 0 : topBarNormalized
243+
const maxY = widgetTallerThanVisibleArea
244+
? Math.max(0, 1 - widgetSize.height)
245+
: Math.max(minY, 1 - widgetSize.height - bottomBarNormalized)
246+
247+
return {
248+
x: constrain(desiredPos.x, 0, Math.max(0, 1 - widgetSize.width)),
249+
y: constrain(desiredPos.y, minY, maxY),
250+
}
251+
}
252+
228253
const handleDrag = (event: MouseEvent): void => {
229254
if (!draggingWidget.value || !initialMousePos.value) return
230255
231256
const viewSize = getViewSize()
232257
const dx = (event.clientX - initialMousePos.value.x) / viewSize.width
233258
const dy = (event.clientY - initialMousePos.value.y) / viewSize.height
234259
235-
const topBarNormalized = widgetStore.currentTopBarHeightPixels / windowHeight.value
236-
const bottomBarNormalized = widgetStore.currentBottomBarHeightPixels / windowHeight.value
237-
const minY = topBarNormalized
238-
const maxY = Math.max(minY, 1 - size.value.height - bottomBarNormalized)
239-
240-
position.value = {
241-
x: constrain(initialWidgetPos.value.x + dx, 0, 1 - size.value.width),
242-
y: constrain(initialWidgetPos.value.y + dy, minY, maxY),
243-
}
260+
position.value = clampPositionToValidArea(
261+
{ x: initialWidgetPos.value.x + dx, y: initialWidgetPos.value.y + dy },
262+
size.value
263+
)
244264
}
245265
246266
const handleResize = (event: MouseEvent): void => {
@@ -327,6 +347,14 @@ onMounted(async () => {
327347
}
328348
widgetStore.widgetManagerVars(widget.value.hash).everMounted = true
329349
350+
// Sanitize persisted layouts so widgets that were previously dragged past the
351+
// visible area (e.g., covering the top/bottom bar) get snapped back into bounds
352+
// automatically on load, without requiring the user to drag them (issue #2608).
353+
const sanitizedPosition = clampPositionToValidArea(position.value, size.value)
354+
if (sanitizedPosition.x !== position.value.x || sanitizedPosition.y !== position.value.y) {
355+
position.value = sanitizedPosition
356+
}
357+
330358
if (widgetResizeHandles.value) {
331359
for (let i = 0; i < widgetResizeHandles.value.length; i++) {
332360
const handle = widgetResizeHandles.value[i]

0 commit comments

Comments
 (0)