From 3f47d4188037a2367c506f88cb4371a420508866 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 11 Aug 2026 09:33:11 +0200
Subject: [PATCH] feat(ui): improve terminal mobile UX and server status
feedback
Add keyboard-aware terminal controls, refine terminal layouts and navigation, surface proxy and Sentinel health warnings, and make avatar uploads update only after successful persistence.
---
.env.development.example | 1 +
app/Livewire/Profile/Index.php | 6 +-
config/constants.php | 1 +
database/seeders/SentinelSeeder.php | 8 ++
resources/css/app.css | 29 +++--
resources/js/terminal.js | 121 +++++++++++++++++-
.../components/modal-confirmation.blade.php | 3 +-
.../views/components/server/sidebar.blade.php | 2 +-
resources/views/livewire/dashboard.blade.php | 4 +
.../views/livewire/profile/index.blade.php | 44 +++++--
.../project/shared/terminal.blade.php | 58 ++++-----
.../views/livewire/server/index.blade.php | 6 +-
.../views/livewire/server/navbar.blade.php | 2 +-
tests/Feature/DeploymentLogsLayoutTest.php | 2 +
tests/Feature/ModalScrollLockTest.php | 9 ++
tests/Feature/ProfileAvatarTest.php | 11 +-
.../Feature/RealtimeTerminalPackagingTest.php | 113 +++++++++++-----
tests/Feature/SentinelSeederTest.php | 28 ++++
.../ServerStatusIndicatorDesignTest.php | 14 +-
tests/Feature/TerminalPageHeaderTest.php | 10 ++
20 files changed, 367 insertions(+), 105 deletions(-)
create mode 100644 tests/Feature/ModalScrollLockTest.php
create mode 100644 tests/Feature/SentinelSeederTest.php
diff --git a/.env.development.example b/.env.development.example
index 29162a59a..6c0fe2189 100644
--- a/.env.development.example
+++ b/.env.development.example
@@ -9,6 +9,7 @@ APP_PORT=8000
APP_DEBUG=true
SSH_MUX_ENABLED=true
COOLIFY_CONTAINER_ROLE=all
+DEV_SENTINEL_URL=
# PostgreSQL Database Configuration
DB_DATABASE=coolify
diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php
index 99c2567f2..a20a1231b 100644
--- a/app/Livewire/Profile/Index.php
+++ b/app/Livewire/Profile/Index.php
@@ -38,7 +38,7 @@ class Index extends Component
public $avatar;
- public function uploadAvatar(AvatarStorageService $avatarStorage): void
+ public function uploadAvatar(AvatarStorageService $avatarStorage): bool
{
try {
$this->validate([
@@ -49,8 +49,12 @@ class Index extends Component
$this->reset('avatar');
$this->dispatch('avatar-updated', url: route('profile.avatar', ['v' => Auth::user()->fresh()->updated_at->timestamp]));
$this->dispatch('success', 'Profile picture updated.');
+
+ return true;
} catch (\Throwable $e) {
handleError($e, $this);
+
+ return false;
}
}
diff --git a/config/constants.php b/config/constants.php
index aa1b5c36c..fc3179059 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -99,6 +99,7 @@ return [
],
'sentinel' => [
+ 'dev_url' => env('DEV_SENTINEL_URL'),
// How often (seconds) PushServerUpdateJob is force-dispatched even when
// the container state hash is unchanged. Keeps exited-detection and
// storage checks from going stale without writing every resource row on
diff --git a/database/seeders/SentinelSeeder.php b/database/seeders/SentinelSeeder.php
index 3cf913933..ebae97078 100644
--- a/database/seeders/SentinelSeeder.php
+++ b/database/seeders/SentinelSeeder.php
@@ -16,6 +16,14 @@ class SentinelSeeder extends Seeder
if (str($server->settings->sentinel_token)->isEmpty()) {
$server->settings->generateSentinelToken(ignoreEvent: true);
}
+ $developmentUrl = isDev() ? config('constants.sentinel.dev_url') : null;
+ if (filled($developmentUrl)) {
+ $server->settings->sentinel_custom_url = $developmentUrl;
+ $server->settings->saveQuietly();
+
+ continue;
+ }
+
if (str($server->settings->sentinel_custom_url)->isEmpty()) {
$url = $server->settings->generateSentinelUrl(ignoreEvent: true);
if (str($url)->isEmpty()) {
diff --git a/resources/css/app.css b/resources/css/app.css
index 1d73bf328..ad3412bf2 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -78,7 +78,13 @@
@layer components {
.terminal-mobile-key {
- @apply min-h-10 rounded-md border border-white/10 bg-white/10 px-2 py-2 text-sm font-semibold text-white shadow-inner active:bg-white/25;
+ @apply min-h-8 shrink-0 rounded-full border bg-transparent px-3 py-1 text-sm font-medium text-neutral-300 active:text-white;
+ border-color: color-mix(in srgb, var(--terminal-scrollbar, #fff) 24%, transparent);
+ }
+
+ .terminal-key-row {
+ border: 1px solid color-mix(in srgb, var(--terminal-scrollbar, #fff) 22%, transparent);
+ background: transparent;
}
/* Active state is a solid fill only (no accent rail / border). */
@@ -714,17 +720,6 @@ html:not(.dark) .application-console-shell[data-console-theme="system"] .termina
color: #52525b;
}
-.terminal-session-expiry {
- font-size: 0.75rem;
- font-weight: 500;
- color: rgb(255 255 255 / 0.6);
-}
-
-html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-session-expiry,
-html:not(.dark) .terminal-fullscreen-shell[data-console-theme="system"] .terminal-session-expiry {
- color: #52525b;
-}
-
.terminal-target-picker {
color: rgb(255 255 255 / 0.75);
background: rgb(0 0 0 / 0.18);
@@ -2457,6 +2452,16 @@ input[type="search"]::-webkit-search-results-decoration {
grid-template-columns: 7.5rem minmax(7rem, 0.8fr) minmax(12rem, 1.7fr) minmax(8rem, 0.9fr) 6.5rem minmax(7rem, 0.8fr);
}
+@media (min-width: 1024px) {
+ .deployment-table-scroll {
+ overflow-x: visible;
+ }
+
+ .deployment-table-grid {
+ min-width: 0;
+ }
+}
+
.dashboard-deployment-table-grid {
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(6.25rem, 0.75fr) 10rem 8rem;
}
diff --git a/resources/js/terminal.js b/resources/js/terminal.js
index bdcc3824d..766bd5f86 100644
--- a/resources/js/terminal.js
+++ b/resources/js/terminal.js
@@ -196,6 +196,14 @@ export function initializeTerminalComponent() {
isDocumentVisible: true,
wasConnectedBeforeHidden: false,
mobileToolbarCollapsed: false,
+ terminalModifier: null,
+ keyboardInset: 0,
+ keyboardAnchorTop: 0,
+ keyboardViewportHeight: 0,
+ keyboardViewportWidth: 0,
+ keyboardInsetSettleTimeout: null,
+ updateKeyboardInset: null,
+ syncKeyboardInset: null,
// Inline style snapshots for ancestors unlocked while fullscreen (no DOM reparenting).
fullscreenAncestorPatches: null,
pageScrollLocked: false,
@@ -212,6 +220,53 @@ export function initializeTerminalComponent() {
init() {
this.starting = this.$el.dataset.autoStart === 'true';
+ this.updateKeyboardInset = () => {
+ const viewport = window.visualViewport;
+ const viewportWidth = viewport?.width ?? window.innerWidth;
+ const layoutHeight = Math.max(
+ window.innerHeight,
+ document.documentElement.clientHeight,
+ viewport ? viewport.height + viewport.offsetTop : 0,
+ );
+
+ // Track the tallest viewport seen at this width — an open software
+ // keyboard shrinks the visual viewport well below it. A large width
+ // change (rotation) resets the baseline.
+ if (Math.abs(this.keyboardViewportWidth - viewportWidth) > 80) {
+ this.keyboardViewportHeight = layoutHeight;
+ } else {
+ this.keyboardViewportHeight = Math.max(this.keyboardViewportHeight, layoutHeight);
+ }
+ this.keyboardViewportWidth = viewportWidth;
+
+ const visualBottom = viewport ? viewport.height + viewport.offsetTop : layoutHeight;
+ this.keyboardInset = window.innerWidth < 640 && viewport
+ ? Math.max(0, Math.round(this.keyboardViewportHeight - visualBottom))
+ : 0;
+ // position:fixed resolves `top` against the layout viewport and
+ // visualViewport.offsetTop is relative to it, so offsetTop + height
+ // is the exact bottom edge of the visible area — a toolbar pinned at
+ // this anchor rides on top of the keyboard no matter how the browser
+ // reports keyboard geometry (iOS overlay or Android layout resize).
+ this.keyboardAnchorTop = Math.round(visualBottom);
+
+ this.syncFullscreenShellWithKeyboard(viewport);
+
+ if (this.fullscreen) {
+ this.$nextTick(() => this.resizeTerminal());
+ }
+ };
+ this.syncKeyboardInset = () => {
+ // iOS fires viewport events mid keyboard animation — re-measure once
+ // the keyboard settles.
+ this.updateKeyboardInset();
+ clearTimeout(this.keyboardInsetSettleTimeout);
+ this.keyboardInsetSettleTimeout = setTimeout(this.updateKeyboardInset, 250);
+ };
+ this.updateKeyboardInset();
+ window.visualViewport?.addEventListener('resize', this.syncKeyboardInset);
+ window.visualViewport?.addEventListener('scroll', this.syncKeyboardInset);
+ window.addEventListener('resize', this.syncKeyboardInset);
this.themeObserver = new MutationObserver(() => {
if (this.selectedTheme === 'system') {
applicationTerminalThemes.system = createSystemTerminalTheme();
@@ -257,7 +312,7 @@ export function initializeTerminalComponent() {
}
this.$nextTick(() => {
if (active) {
- this.$refs.terminalWrapper.style.display = 'block';
+ this.$refs.terminalWrapper.style.removeProperty('display');
this.resizeTerminal();
// Start observing terminal wrapper for resize changes
@@ -266,8 +321,11 @@ export function initializeTerminalComponent() {
}
} else {
const terminalElement = document.getElementById('terminal');
- this.$refs.terminalWrapper.style.display =
- terminalElement?.dataset.terminalStyle === 'application' ? 'block' : 'none';
+ if (terminalElement?.dataset.terminalStyle === 'application') {
+ this.$refs.terminalWrapper.style.removeProperty('display');
+ } else {
+ this.$refs.terminalWrapper.style.display = 'none';
+ }
// Stop observing when terminal is inactive
if (this.resizeObserver) {
@@ -305,6 +363,10 @@ export function initializeTerminalComponent() {
},
cleanup() {
+ window.visualViewport?.removeEventListener('resize', this.syncKeyboardInset);
+ window.visualViewport?.removeEventListener('scroll', this.syncKeyboardInset);
+ window.removeEventListener('resize', this.syncKeyboardInset);
+ clearTimeout(this.keyboardInsetSettleTimeout);
this.checkIfProcessIsRunningAndKillIt();
this.clearAllTimers();
this.connectionState = 'disconnected';
@@ -848,6 +910,10 @@ export function initializeTerminalComponent() {
destroy() {
this.themeObserver?.disconnect();
+ window.visualViewport?.removeEventListener('resize', this.syncKeyboardInset);
+ window.visualViewport?.removeEventListener('scroll', this.syncKeyboardInset);
+ window.removeEventListener('resize', this.syncKeyboardInset);
+ clearTimeout(this.keyboardInsetSettleTimeout);
},
@@ -856,7 +922,6 @@ export function initializeTerminalComponent() {
return;
}
- this.term.focus();
this.sendMessage({ message: data });
},
@@ -868,14 +933,35 @@ export function initializeTerminalComponent() {
arrowLeft: '\x1b[D',
tab: '\t',
escape: '\x1b',
- ctrlC: '\x03'
+ ctrlC: '\x03',
+ ctrlBackslash: '\x1c',
+ ctrlS: '\x13',
+ ctrlZ: '\x1a'
};
if (terminalSequences[sequence]) {
+ this.terminalModifier = null;
this.sendTerminalInput(terminalSequences[sequence]);
}
},
+ toggleTerminalModifier(modifier) {
+ this.terminalModifier = this.terminalModifier === modifier ? null : modifier;
+ },
+
+ sendTerminalKey(key) {
+ let input = key;
+
+ if (this.terminalModifier === 'ctrl') {
+ input = String.fromCharCode(key.toUpperCase().charCodeAt(0) & 31);
+ } else if (this.terminalModifier === 'alt') {
+ input = `\x1b${key}`;
+ }
+
+ this.terminalModifier = null;
+ this.sendTerminalInput(input);
+ },
+
async pasteFromClipboard() {
if (!navigator.clipboard?.readText) {
this.$wire.dispatch('error', 'Clipboard paste is not available in this browser.');
@@ -979,6 +1065,29 @@ export function initializeTerminalComponent() {
this.sendMessage({ checkActive: 'force' });
},
+ /**
+ * While the software keyboard is open, shrink the fullscreen shell to the
+ * visual viewport so xterm rows and the mobile key row stay visible above
+ * the keyboard. Inline !important is required to outrank the stylesheet's
+ * `inset: 0 !important` / `height: auto !important` fullscreen rules.
+ */
+ syncFullscreenShellWithKeyboard(viewport) {
+ const wrapper = this.$refs.terminalWrapper;
+ if (!wrapper) {
+ return;
+ }
+
+ if (this.fullscreen && viewport && this.keyboardInset > 0) {
+ wrapper.style.setProperty('top', `${Math.round(viewport.offsetTop)}px`, 'important');
+ wrapper.style.setProperty('height', `${Math.round(viewport.height)}px`, 'important');
+ wrapper.style.setProperty('bottom', 'auto', 'important');
+ } else {
+ wrapper.style.removeProperty('top');
+ wrapper.style.removeProperty('height');
+ wrapper.style.removeProperty('bottom');
+ }
+ },
+
makeFullscreen() {
if (this.fullscreen) {
this.exitFullscreen();
@@ -1012,6 +1121,7 @@ export function initializeTerminalComponent() {
this.fullscreen = true;
document.documentElement.classList.add('terminal-is-fullscreen');
document.body.classList.add('terminal-is-fullscreen');
+ this.updateKeyboardInset?.();
this.scheduleTerminalResize();
},
@@ -1032,6 +1142,7 @@ export function initializeTerminalComponent() {
// Recover from older portal builds that left the terminal on
.
this.salvageStrayFullscreenNodes();
+ this.updateKeyboardInset?.();
this.scheduleTerminalResize();
},
diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php
index 4c0a63b78..8cc233a0c 100644
--- a/resources/views/components/modal-confirmation.blade.php
+++ b/resources/views/components/modal-confirmation.blade.php
@@ -328,7 +328,8 @@
class="w-auto" isError
@click="
if (dispatchEvent) {
- $wire.dispatch(dispatchEventType, dispatchEventMessage);
+ modalOpen = false;
+ $nextTick(() => $wire.dispatch(dispatchEventType, dispatchEventMessage));
}
if (confirmWithPassword && !skipPasswordConfirmation) {
step++;
diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php
index d0254054b..006efb2ca 100644
--- a/resources/views/components/server/sidebar.blade.php
+++ b/resources/views/components/server/sidebar.blade.php
@@ -83,7 +83,7 @@
[
'label' => 'Terminal',
'route' => 'server.command',
- 'active' => request()->routeIs('server.command'),
+ 'active' => $activeMenu === 'terminal',
'icon' => 'browser-terminal',
'group' => 'Operations',
'navigate' => false,
diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php
index 652dbb0e2..eb48314bf 100644
--- a/resources/views/livewire/dashboard.blade.php
+++ b/resources/views/livewire/dashboard.blade.php
@@ -169,11 +169,15 @@
@foreach ($dashboardServers as $server)
@php
+ $proxyNeedsAttention = $server->proxySet() && $server->proxy->status !== 'running';
+ $sentinelNeedsAttention = $server->isSentinelEnabled() && ! $server->isSentinelLive();
+
[$serverStatus, $serverStatusType] = match (true) {
$server->settings->force_disabled => ['Disabled', 'error'],
! $server->settings->is_reachable && ! $server->settings->is_usable => ['Unavailable', 'error'],
! $server->settings->is_reachable => ['Unreachable', 'error'],
! $server->settings->is_usable => ['Not ready', 'warning'],
+ $proxyNeedsAttention || $sentinelNeedsAttention => ['Attention required', 'warning'],
default => ['Ready', 'success'],
};
@endphp
diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php
index 090be9fd9..8ad2e9207 100644
--- a/resources/views/livewire/profile/index.blade.php
+++ b/resources/views/livewire/profile/index.blade.php
@@ -52,9 +52,25 @@
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(value => value ? resolve(value) : reject(new Error('JPEG compression failed')), 'image/jpeg', 0.8);
});
- this.preview = URL.createObjectURL(blob);
+ const previewUrl = URL.createObjectURL(blob);
const compressed = new File([blob], 'avatar.jpg', { type: 'image/jpeg' });
- this.$wire.upload('avatar', compressed, () => this.processing = false, () => {
+ this.$wire.upload('avatar', compressed, async () => {
+ try {
+ const uploaded = await this.$wire.uploadAvatar();
+ if (uploaded) {
+ if (this.preview) URL.revokeObjectURL(this.preview);
+ this.preview = previewUrl;
+ } else {
+ URL.revokeObjectURL(previewUrl);
+ }
+ } catch (error) {
+ URL.revokeObjectURL(previewUrl);
+ this.uploadError = 'The image could not be uploaded.';
+ } finally {
+ this.processing = false;
+ }
+ }, () => {
+ URL.revokeObjectURL(previewUrl);
this.processing = false;
this.uploadError = 'The image could not be uploaded.';
});
@@ -84,22 +100,22 @@
@endif
-
+
+
+
+
+
+ @if (auth()->user()->avatar_path)
+ Remove
+ @endif
+
@error('avatar')
{{ $message }}
@enderror
-
-
- Upload picture
- Compressing…
-
- @if (auth()->user()->avatar_path)
- Remove
- @endif
-
diff --git a/resources/views/livewire/project/shared/terminal.blade.php b/resources/views/livewire/project/shared/terminal.blade.php
index a96a28ab1..e219ceef5 100644
--- a/resources/views/livewire/project/shared/terminal.blade.php
+++ b/resources/views/livewire/project/shared/terminal.blade.php
@@ -60,10 +60,6 @@
x-text="connectionState === 'reconnecting' ? `reconnecting… (attempt ${reconnectAttempts})` : (starting ? 'connecting…' : (connectionState === 'connecting' ? 'connecting…' : 'choose a container to start a session'))">
-
-
@else
-
-
- Terminal keys
-
-
-
-
-
-
-
-
-
-
+ :class="fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : (keyboardInset > 0 ? 'fixed inset-x-0 z-[100002] px-2 pb-2' : 'relative z-[2] mt-2 shrink-0')"
+ :style="!fullscreen && keyboardInset > 0 ? `top: ${keyboardAnchorTop}px; transform: translateY(-100%)` : ''"
+ data-terminal-mobile-toolbar>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -124,7 +114,7 @@
')
+ ->toContain("toggleTerminalModifier('ctrl')")
+ ->toContain("toggleTerminalModifier('alt')")
+ ->toContain("sendTerminalKey('/')")
+ ->toContain("sendTerminalKey('|')")
+ ->toContain("sendTerminalKey('~')")
+ ->toContain("sendTerminalKey('-')")
+ ->toContain("sendTerminalControl('ctrlC')")
+ ->toContain("sendTerminalControl('ctrlBackslash')")
+ ->toContain("sendTerminalControl('ctrlS')")
+ ->toContain("sendTerminalControl('ctrlZ')")
+ ->not->toContain("sendTerminalControl('arrowUp')")
+ ->toContain("fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : 'relative z-[2] mt-2 shrink-0'")
->toContain('data-terminal-mobile-toolbar')
->and($appCss)
- ->toContain('.terminal-mobile-key');
+ ->toContain('.terminal-mobile-key')
+ ->toContain('min-h-8')
+ ->toContain('rounded-full')
+ ->toContain('.terminal-key-row')
+ ->toContain('background: transparent;')
+ ->toContain('var(--terminal-scrollbar');
+});
+
+it('shows the terminal key row outside fullscreen mode', function () {
+ $terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
+ $terminalClient = file_get_contents(resource_path('js/terminal.js'));
+
+ expect($terminalView)
+ ->toContain("fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : 'relative z-[2] mt-2 shrink-0'")
+ ->not->toContain('class="sm:hidden" data-terminal-mobile-toolbar')
+ ->toContain(':style="!fullscreen && keyboardInset > 0 ? `top: ${keyboardAnchorTop}px; transform: translateY(-100%)` : \'\'"')
+ ->and($terminalClient)
+ ->toContain("this.\$refs.terminalWrapper.style.removeProperty('display')")
+ ->not->toContain("this.\$refs.terminalWrapper.style.display = 'block'");
});
it('sends terminal mobile toolbar controls through the websocket', function () {
@@ -365,8 +384,15 @@ it('sends terminal mobile toolbar controls through the websocket', function () {
->toContain("tab: '\\t'")
->toContain("escape: '\\x1b'")
->toContain("ctrlC: '\\x03'")
+ ->toContain("ctrlBackslash: '\\x1c'")
+ ->toContain("ctrlS: '\\x13'")
+ ->toContain("ctrlZ: '\\x1a'")
+ ->toContain('toggleTerminalModifier(modifier)')
+ ->toContain('sendTerminalKey(key)')
->toContain('navigator.clipboard.readText()')
- ->toContain('navigator.clipboard.writeText(selection)');
+ ->toContain('navigator.clipboard.writeText(selection)')
+ ->toContain("sendTerminalInput(data) {\n if (!this.term || !this.terminalActive) {\n return;\n }\n\n this.sendMessage({ message: data });")
+ ->not->toContain("sendTerminalInput(data) {\n if (!this.term || !this.terminalActive) {\n return;\n }\n\n this.term.focus();");
});
it('uses terminal host dimensions when resizing so mobile controls do not cover terminal rows', function () {
@@ -379,24 +405,40 @@ it('uses terminal host dimensions when resizing so mobile controls do not cover
->not->toContain('const wrapperHeight = this.$refs.terminalWrapper.clientHeight;');
});
-it('uses simple fullscreen bottom margin based on mobile toolbar visibility', function () {
+it('keeps the fullscreen mobile toolbar above the software keyboard', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
expect($terminalClient)
- ->not->toContain('updateFullscreenLayout()')
- ->not->toContain('terminalFullscreenHeight')
- ->not->toContain('window.visualViewport?.height')
+ ->toContain('keyboardInset: 0')
+ ->toContain('keyboardAnchorTop: 0')
+ ->toContain('keyboardViewportHeight: 0')
+ ->toContain('updateKeyboardInset()')
+ ->toContain('window.visualViewport')
+ ->toContain('viewport.height + viewport.offsetTop')
+ ->toContain('this.keyboardViewportHeight - visualBottom')
+ ->toContain('this.keyboardAnchorTop = Math.round(visualBottom)')
+ ->toContain('syncFullscreenShellWithKeyboard(viewport)')
+ ->toContain("wrapper.style.setProperty('bottom', 'auto', 'important')")
+ ->toContain("window.visualViewport?.addEventListener('resize', this.syncKeyboardInset)")
+ ->toContain("window.visualViewport?.addEventListener('scroll', this.syncKeyboardInset)")
+ ->toContain("window.addEventListener('resize', this.syncKeyboardInset)")
+ ->toContain("window.visualViewport?.removeEventListener('resize', this.syncKeyboardInset)")
+ ->toContain("window.visualViewport?.removeEventListener('scroll', this.syncKeyboardInset)")
+ ->toContain("window.removeEventListener('resize', this.syncKeyboardInset)")
->and($terminalView)
- ->toContain("mobileToolbarCollapsed\n ? 'terminal-host relative z-[1] min-h-0 flex-1 overflow-hidden px-1 py-[5px] bg-transparent max-sm:pb-14'\n : 'terminal-host relative z-[1] min-h-0 flex-1 overflow-hidden px-1 py-[5px] bg-transparent max-sm:pb-24'")
- ->toContain("fullscreen ? 'absolute inset-x-0 bottom-0 z-[2] px-2 pb-2'");
+ ->toContain("'terminal-host relative z-[1] min-h-0 flex-1 overflow-hidden px-1 py-[5px] bg-transparent'")
+ ->toContain("fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2'")
+ ->toContain(':style="!fullscreen && keyboardInset > 0 ? `top: ${keyboardAnchorTop}px; transform: translateY(-100%)` : \'\'"')
+ ->toContain("fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : (keyboardInset > 0 ? 'fixed inset-x-0 z-[100002] px-2 pb-2'");
});
-it('resizes after toggling the mobile terminal toolbar', function () {
- $terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
+it('resizes after the mobile keyboard viewport changes', function () {
+ $terminalClient = file_get_contents(resource_path('js/terminal.js'));
- expect($terminalView)
- ->toContain('$nextTick(() => resizeTerminal())');
+ expect($terminalClient)
+ ->toContain('window.visualViewport')
+ ->toContain('this.$nextTick(() => this.resizeTerminal())');
});
it('uses fixed viewport positioning for fullscreen terminal instead of inherited container size', function () {
@@ -438,6 +480,13 @@ it('keeps enter and exit fullscreen controls the same size and chrome', function
->toContain('color-mix(in srgb, var(--terminal-scrollbar');
});
+it('keeps the application terminal fullscreen control visible on mobile', function () {
+ $terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
+
+ expect($terminalView)
+ ->toContain('opacity-100 sm:opacity-0 sm:group-hover/terminal:opacity-100 sm:focus-visible:opacity-100');
+});
+
it('lets the selected theme show through the active terminal panel', function () {
$appCss = file_get_contents(resource_path('css/app.css'));
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
diff --git a/tests/Feature/SentinelSeederTest.php b/tests/Feature/SentinelSeederTest.php
new file mode 100644
index 000000000..b905bfcbe
--- /dev/null
+++ b/tests/Feature/SentinelSeederTest.php
@@ -0,0 +1,28 @@
+insert(['id' => 0]);
+ $user = User::factory()->create();
+ $server = Server::factory()->create([
+ 'team_id' => $user->teams()->first()->id,
+ ]);
+ DB::table('server_settings')->where('id', $server->settings->id)->update([
+ 'sentinel_custom_url' => 'http://host.docker.internal:8000',
+ ]);
+
+ config()->set('app.env', 'local');
+ config()->set('constants.sentinel.dev_url', 'https://coolify-dev.example.com:8000');
+
+ app(SentinelSeeder::class)->run();
+
+ expect($server->settings->fresh()->sentinel_custom_url)
+ ->toBe('https://coolify-dev.example.com:8000');
+});
diff --git a/tests/Feature/ServerStatusIndicatorDesignTest.php b/tests/Feature/ServerStatusIndicatorDesignTest.php
index e29857c49..34e0f4cbd 100644
--- a/tests/Feature/ServerStatusIndicatorDesignTest.php
+++ b/tests/Feature/ServerStatusIndicatorDesignTest.php
@@ -14,9 +14,12 @@ test('server cards use icon borders instead of ready badges', function () {
expect(substr_count($serverIndex, '
toBe(1)
->and($serverIndex)
+ ->toContain("\$proxyNeedsAttention = \$isReady && \$server->proxySet() && \$server->proxy->status !== 'running'")
+ ->toContain('$sentinelNeedsAttention = $isReady && $server->isSentinelEnabled() && ! $server->isSentinelLive()')
+ ->toContain("\$proxyNeedsAttention || \$sentinelNeedsAttention => 'warning'")
->toContain("\$isReady => 'success'")
->toContain("\$isTransferredAway || \$server->settings->force_disabled => 'error'")
- ->toContain("default => 'warning'")
+ ->toContain("default => 'error'")
->toContain("server.statusType === 'success' ? 'border-emerald-500/70'")
->toContain("server.statusType === 'warning' ? 'border-amber-500/70'")
->toContain("'border-red-500/70'")
@@ -24,6 +27,15 @@ test('server cards use icon borders instead of ready badges', function () {
->toContain(':aria-label="`Server status: ${server.status}`"');
});
+test('dashboard server cards warn when proxy or sentinel needs attention', function () {
+ $dashboard = file_get_contents(resource_path('views/livewire/dashboard.blade.php'));
+
+ expect($dashboard)
+ ->toContain("\$proxyNeedsAttention = \$server->proxySet() && \$server->proxy->status !== 'running'")
+ ->toContain('$sentinelNeedsAttention = $server->isSentinelEnabled() && ! $server->isSentinelLive()')
+ ->toContain("\$proxyNeedsAttention || \$sentinelNeedsAttention => ['Attention required', 'warning']");
+});
+
test('server table keeps status text without a badge', function () {
$serverIndex = file_get_contents(resource_path('views/livewire/server/index.blade.php'));
diff --git a/tests/Feature/TerminalPageHeaderTest.php b/tests/Feature/TerminalPageHeaderTest.php
index d4bfd925a..6e1308608 100644
--- a/tests/Feature/TerminalPageHeaderTest.php
+++ b/tests/Feature/TerminalPageHeaderTest.php
@@ -80,6 +80,16 @@ it('opens the global terminal outside Livewire navigation like resource terminal
->not->toMatch('/]*wireNavigate\(\)/s');
});
+it('keeps the server terminal navigation active during Livewire requests', function () {
+ $sidebar = file_get_contents(resource_path('views/components/server/sidebar.blade.php'));
+ $navbar = file_get_contents(resource_path('views/livewire/server/navbar.blade.php'));
+
+ expect($sidebar)
+ ->toContain("'active' => \$activeMenu === 'terminal'")
+ ->and($navbar)
+ ->toContain("'active' => \$currentRoute === 'server.command'");
+});
+
it('uses floating rounded controls instead of the legacy terminal header bar', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));