mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
fix(mobile): terminal and layout fixes for touch devices
Mobile-focused fixes for the web UI: keyboard-accessory layout and overlap, native input visibility above the keyboard, CJK input handling, terminal touch scrolling, tab-menu tap targets, mic-recording glow containment, and mobile resize/keyboard-state handling on tab switch, plus mobile visual-regression test coverage and snapshots. Co-Authored-By: Saqeb Akhter <saqeb.akhter@gmail.com>
This commit is contained in:
committed by
arkon
co-authored by
Saqeb Akhter
parent
c29475ed10
commit
02fa3f30f5
+39
-4
@@ -576,6 +576,7 @@ class CodemanApp {
|
||||
// Apply keyboard bar mode from settings
|
||||
const _kbSettings = this.loadAppSettingsFromStorage();
|
||||
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
|
||||
this.bindMobileHeaderUtilityToggle?.();
|
||||
this.applyHeaderVisibilitySettings();
|
||||
this.applyTabWrapSettings();
|
||||
this.applyMonitorVisibility();
|
||||
@@ -2026,9 +2027,20 @@ class CodemanApp {
|
||||
const cjkEl = document.getElementById('cjkInput');
|
||||
if (!cjkEl) return;
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const showCjk = this._serverCjkOverride || settings.cjkInputEnabled || false;
|
||||
const defaults = this.getDefaultSettings?.() || {};
|
||||
const isTouchTerminal =
|
||||
typeof MobileDetection !== 'undefined' &&
|
||||
MobileDetection.isTouchDevice() &&
|
||||
(MobileDetection.isSmallScreen() || MobileDetection.isMediumScreen());
|
||||
const showCjk =
|
||||
this._serverCjkOverride || (!isTouchTerminal && (settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false));
|
||||
cjkEl.classList.toggle('cjk-input-visible', !!showCjk);
|
||||
document.body.classList.toggle('cjk-input-visible', !!showCjk);
|
||||
cjkEl.style.display = showCjk ? 'block' : 'none';
|
||||
cjkEl.setAttribute('aria-hidden', showCjk ? 'false' : 'true');
|
||||
if (showCjk && cjkEl.value === '\u200B') cjkEl.value = '';
|
||||
if (!showCjk) window.cjkActive = false;
|
||||
if (typeof KeyboardHandler !== 'undefined') KeyboardHandler.updateLayoutForKeyboard();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2078,6 +2090,7 @@ class CodemanApp {
|
||||
KeyboardHandler.cleanup();
|
||||
MobileDetection.init();
|
||||
KeyboardHandler.init();
|
||||
this.bindMobileHeaderUtilityToggle?.();
|
||||
// Clear tab alerts
|
||||
this.tabAlerts.clear();
|
||||
// Clear shown completions (used for duplicate notification prevention)
|
||||
@@ -2550,7 +2563,7 @@ class CodemanApp {
|
||||
const tallTabsEnabled = this._tallTabsEnabled ?? false;
|
||||
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
|
||||
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.selectSession('${escapeHtml(id)}', { forceReload: true })" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, '${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
@@ -2631,6 +2644,15 @@ class CodemanApp {
|
||||
container.addEventListener('keydown', this._tabKeydownHandler);
|
||||
}
|
||||
|
||||
handleSessionTabClick(event, sessionId) {
|
||||
event?.preventDefault?.();
|
||||
const preserveKeyboard = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible === true;
|
||||
if (!preserveKeyboard && MobileDetection.isTouchDevice()) {
|
||||
document.activeElement?.blur?.();
|
||||
}
|
||||
return this.selectSession(sessionId, { forceReload: true, preserveKeyboard });
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Tab Order and Drag-and-Drop
|
||||
@@ -2901,6 +2923,13 @@ class CodemanApp {
|
||||
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
|
||||
}
|
||||
|
||||
_shouldFocusTerminalForTabSwitch() {
|
||||
if (typeof MobileDetection === 'undefined' || !MobileDetection.isTouchDevice()) {
|
||||
return true;
|
||||
}
|
||||
return typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible;
|
||||
}
|
||||
|
||||
async selectSession(sessionId, options = {}) {
|
||||
// If this session is popped out into its own window, raise that window
|
||||
// instead of showing it inline (focus-on-click for detached tabs). If we
|
||||
@@ -2925,7 +2954,13 @@ class CodemanApp {
|
||||
// programmatic focus() within the user-gesture call stack (e.g. tab click).
|
||||
// After the first await the gesture context is lost and focus() is silently
|
||||
// ignored, leaving the keyboard unable to send input to the terminal.
|
||||
if (this.terminal) this.terminal.focus();
|
||||
const shouldFocusTerminal =
|
||||
options?.preserveKeyboard === true
|
||||
? this._shouldFocusTerminalForTabSwitch()
|
||||
: options?.preserveKeyboard === false
|
||||
? false
|
||||
: this._shouldFocusTerminalForTabSwitch();
|
||||
if (shouldFocusTerminal && this.terminal) this.terminal.focus();
|
||||
|
||||
const _selStart = performance.now();
|
||||
const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8);
|
||||
@@ -3231,7 +3266,7 @@ class CodemanApp {
|
||||
this._connectWs(sessionId);
|
||||
|
||||
_crashDiag.log('FOCUS');
|
||||
this.terminal.focus();
|
||||
if (shouldFocusTerminal && this.terminal) this.terminal.focus();
|
||||
this.scrollToLastNonEmptyLine();
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
_crashDiag.log(`SELECT_DONE: ${(performance.now() - _selStart).toFixed(0)}ms`);
|
||||
|
||||
@@ -69,15 +69,15 @@
|
||||
<span class="logo" onclick="app.goHome()" title="Go to main page">Codeman</span>
|
||||
</div>
|
||||
|
||||
<button class="mobile-header-utility-toggle" id="mobileHeaderUtilityToggle" type="button" aria-label="Toggle header utilities" aria-controls="headerRight" aria-expanded="false" title="Header utilities">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- Session Tabs -->
|
||||
<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs">
|
||||
</div>
|
||||
|
||||
<!-- Detached single-session window title (shown only in solo mode) -->
|
||||
<div class="solo-session-title" id="soloSessionTitle" style="display: none;" aria-live="polite"></div>
|
||||
|
||||
<div class="header-right">
|
||||
<button class="btn-icon-header btn-solo-redock" id="soloRedockBtn" style="display: none;" onclick="window.close()" title="Re-dock to dashboard (close window)" aria-label="Re-dock session to dashboard">⊞</button>
|
||||
<div class="header-right mobile-collapsed" id="headerRight">
|
||||
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
|
||||
<span class="tunnel-dot"></span>
|
||||
</button>
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const CjkInput = (() => {
|
||||
let _textarea = null;
|
||||
let _terminalContainer = null;
|
||||
let _xtermTextarea = null;
|
||||
let _send = null;
|
||||
let _initialized = false;
|
||||
let _composing = false;
|
||||
@@ -75,6 +77,23 @@ const CjkInput = (() => {
|
||||
_textarea.setSelectionRange(1, 1);
|
||||
}
|
||||
|
||||
function _isMobileComposer() {
|
||||
return !!(
|
||||
_textarea &&
|
||||
typeof MobileDetection !== 'undefined' &&
|
||||
MobileDetection.isTouchDevice() &&
|
||||
_textarea.classList.contains('cjk-input-visible')
|
||||
);
|
||||
}
|
||||
|
||||
function _resetInput() {
|
||||
if (_isMobileComposer()) {
|
||||
_textarea.value = '';
|
||||
} else {
|
||||
_resetToPhantom();
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if textarea contains only phantom(s) or is empty — no real user text */
|
||||
function _isEffectivelyEmpty() {
|
||||
return !_strip(_textarea.value);
|
||||
@@ -97,24 +116,44 @@ const CjkInput = (() => {
|
||||
_composing = false;
|
||||
_textarea = document.getElementById('cjkInput');
|
||||
if (!_textarea) return this;
|
||||
_terminalContainer = document.getElementById('terminalContainer');
|
||||
|
||||
// Seed the phantom character
|
||||
_resetToPhantom();
|
||||
// Seed the phantom character for the hidden/immediate CJK path.
|
||||
_resetInput();
|
||||
|
||||
_listeners.mousedown = (e) => { e.stopPropagation(); };
|
||||
_listeners.focus = () => {
|
||||
window.cjkActive = true;
|
||||
if (_isMobileComposer() && _textarea.value === PHANTOM) {
|
||||
_textarea.value = '';
|
||||
return;
|
||||
}
|
||||
// Restore phantom if textarea was emptied while blurred
|
||||
if (!_textarea.value) _resetToPhantom();
|
||||
if (!_textarea.value && !_isMobileComposer()) _resetToPhantom();
|
||||
};
|
||||
_listeners.blur = () => { window.cjkActive = false; };
|
||||
_textarea.addEventListener('mousedown', _listeners.mousedown);
|
||||
_textarea.addEventListener('focus', _listeners.focus);
|
||||
_textarea.addEventListener('blur', _listeners.blur);
|
||||
|
||||
_listeners.xtermFocusRedirect = () => {
|
||||
if (!_isMobileComposer()) return;
|
||||
_textarea.focus();
|
||||
};
|
||||
if (_terminalContainer) {
|
||||
_xtermTextarea = _terminalContainer.querySelector('.xterm-helper-textarea');
|
||||
if (_xtermTextarea) {
|
||||
_xtermTextarea.addEventListener('focus', _listeners.xtermFocusRedirect, { capture: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Composition tracking ──
|
||||
_listeners.compositionstart = () => {
|
||||
_composing = true;
|
||||
if (_isMobileComposer()) {
|
||||
if (_textarea.value === PHANTOM) _textarea.value = '';
|
||||
return;
|
||||
}
|
||||
// Clear phantom so IME sees a clean textarea — some IMEs include
|
||||
// existing text in the composition region which would corrupt input.
|
||||
if (_textarea.value === PHANTOM) {
|
||||
@@ -123,6 +162,7 @@ const CjkInput = (() => {
|
||||
};
|
||||
_listeners.compositionend = () => {
|
||||
_composing = false;
|
||||
if (_isMobileComposer()) return;
|
||||
// Defer flush: some Android IMEs haven't committed text to textarea
|
||||
// when compositionend fires. setTimeout(0) ensures we read the final value.
|
||||
setTimeout(_flush, 0);
|
||||
@@ -145,7 +185,7 @@ const CjkInput = (() => {
|
||||
} else {
|
||||
_send('\r');
|
||||
}
|
||||
_resetToPhantom();
|
||||
_resetInput();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -153,7 +193,7 @@ const CjkInput = (() => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
_composing = false;
|
||||
_resetToPhantom();
|
||||
_resetInput();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -167,6 +207,19 @@ const CjkInput = (() => {
|
||||
// Below: only when NOT composing (composing keystrokes belong to IME)
|
||||
if (_composing) return;
|
||||
|
||||
if (_isMobileComposer()) {
|
||||
if (e.key === 'Backspace' && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send('\x7f');
|
||||
return;
|
||||
}
|
||||
if (PASSTHROUGH_KEYS[e.key] && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send(PASSTHROUGH_KEYS[e.key]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace: forward to PTY when no real text in textarea
|
||||
// (Desktop path — Android uses the input event + phantom approach)
|
||||
if (e.key === 'Backspace' && _isEffectivelyEmpty()) {
|
||||
@@ -198,6 +251,13 @@ const CjkInput = (() => {
|
||||
// making keydown unreliable. input fires AFTER character insertion and
|
||||
// carries inputType which tells us whether the text is final or tentative.
|
||||
_listeners.input = (e) => {
|
||||
if (_isMobileComposer()) {
|
||||
if (_textarea.value.includes(PHANTOM)) {
|
||||
_textarea.value = _strip(_textarea.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Backspace / delete detection ──
|
||||
// Android long-press backspace generates rapid deleteContentBackward events.
|
||||
// The phantom character ensures the textarea is never truly empty, so each
|
||||
@@ -245,8 +305,13 @@ const CjkInput = (() => {
|
||||
if (handler) _textarea.removeEventListener(event, handler);
|
||||
}
|
||||
}
|
||||
if (_xtermTextarea && _listeners.xtermFocusRedirect) {
|
||||
_xtermTextarea.removeEventListener('focus', _listeners.xtermFocusRedirect, { capture: true });
|
||||
}
|
||||
window.cjkActive = false;
|
||||
_composing = false;
|
||||
_terminalContainer = null;
|
||||
_xtermTextarea = null;
|
||||
for (const key of Object.keys(_listeners)) delete _listeners[key];
|
||||
_initialized = false;
|
||||
},
|
||||
|
||||
@@ -255,10 +255,9 @@ const KeyboardHandler = {
|
||||
if (heightDiff > 150 && !this.keyboardVisible) {
|
||||
this.keyboardVisible = true;
|
||||
document.body.classList.add('keyboard-visible');
|
||||
// Restore --app-height: MobileDetection's resize listener fires before ours
|
||||
// and may have already shrunk it for the keyboard viewport change.
|
||||
// Use initialViewportHeight (captured before keyboard opened).
|
||||
document.documentElement.style.setProperty('--app-height', `${this.initialViewportHeight}px`);
|
||||
// While the keyboard is open, size the app to the visual viewport so
|
||||
// xterm's bottom row and cursor sit above the OS keyboard.
|
||||
document.documentElement.style.setProperty('--app-height', `${currentHeight}px`);
|
||||
this.onKeyboardShow();
|
||||
}
|
||||
// Keyboard hidden (viewport grew back close to initial)
|
||||
@@ -277,6 +276,8 @@ const KeyboardHandler = {
|
||||
// state changes, orientation changes, and other viewport shifts
|
||||
if (!this.keyboardVisible) {
|
||||
this.initialViewportHeight = currentHeight;
|
||||
} else {
|
||||
document.documentElement.style.setProperty('--app-height', `${currentHeight}px`);
|
||||
}
|
||||
|
||||
this.updateLayoutForKeyboard();
|
||||
@@ -295,6 +296,7 @@ const KeyboardHandler = {
|
||||
|
||||
const toolbar = document.querySelector('.toolbar');
|
||||
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
||||
const cjkInput = document.getElementById('cjkInput');
|
||||
const main = document.querySelector('.main');
|
||||
|
||||
if (this.keyboardVisible) {
|
||||
@@ -302,7 +304,8 @@ const KeyboardHandler = {
|
||||
// translate up so it sits at the bottom of the visual viewport.
|
||||
// This formula accounts for iOS scrolling the visual viewport (offsetTop)
|
||||
// when the user types in xterm's hidden textarea.
|
||||
const layoutHeight = window.innerHeight;
|
||||
const appEl = document.querySelector('.app');
|
||||
const layoutHeight = appEl?.getBoundingClientRect().bottom || window.innerHeight;
|
||||
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
|
||||
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
|
||||
|
||||
@@ -317,13 +320,17 @@ const KeyboardHandler = {
|
||||
if (accessoryBar) {
|
||||
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
}
|
||||
if (cjkInput?.classList.contains('cjk-input-visible')) {
|
||||
cjkInput.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
}
|
||||
|
||||
// Shrink main content area so terminal doesn't extend behind keyboard.
|
||||
// Use stable keyboard height (not scroll-dependent) for padding.
|
||||
// 84px = toolbar (40px) + accessory bar (44px).
|
||||
// Reserve only Codeman's visible controls. The OS keyboard is outside
|
||||
// the visual viewport; adding its height here creates a large blank area
|
||||
// above the mobile toolbar on iPhone.
|
||||
const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight);
|
||||
if (main && keyboardHeight > 0) {
|
||||
main.style.paddingBottom = `${keyboardHeight + 84}px`;
|
||||
const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
|
||||
main.style.paddingBottom = `${84 + cjkInputHeight}px`;
|
||||
}
|
||||
} else {
|
||||
this.resetLayout();
|
||||
@@ -334,6 +341,7 @@ const KeyboardHandler = {
|
||||
resetLayout() {
|
||||
const toolbar = document.querySelector('.toolbar');
|
||||
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
||||
const cjkInput = document.getElementById('cjkInput');
|
||||
const main = document.querySelector('.main');
|
||||
|
||||
if (toolbar) {
|
||||
@@ -342,6 +350,9 @@ const KeyboardHandler = {
|
||||
if (accessoryBar) {
|
||||
accessoryBar.style.transform = '';
|
||||
}
|
||||
if (cjkInput) {
|
||||
cjkInput.style.transform = '';
|
||||
}
|
||||
if (main) {
|
||||
main.style.paddingBottom = '';
|
||||
}
|
||||
@@ -376,6 +387,8 @@ const KeyboardHandler = {
|
||||
// to the accessory bar.
|
||||
this._shrinkPaddingToFit();
|
||||
app.terminal.scrollToBottom();
|
||||
app._syncMobileHelperTextareaToCursor?.();
|
||||
app._localEchoOverlay?.rerender?.();
|
||||
// Send resize to server so PTY dimensions match xterm
|
||||
this._sendTerminalResize();
|
||||
}
|
||||
|
||||
+157
-9
@@ -55,7 +55,9 @@ html.mobile-init .file-browser-panel {
|
||||
padding-right: calc(0.5rem + var(--safe-area-right));
|
||||
background: #0a0a0a;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
z-index: 200;
|
||||
contain: none;
|
||||
overflow: visible;
|
||||
z-index: 1200;
|
||||
}
|
||||
|
||||
/* iOS safe area adjustment for fixed header - header extends into notch area */
|
||||
@@ -92,8 +94,60 @@ html.mobile-init .file-browser-panel {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.mobile-header-utility-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
margin: -4px 0.2rem -4px 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
order: -1;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-header-utility-toggle.active,
|
||||
.mobile-header-utility-toggle:active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
position: fixed;
|
||||
top: calc(52px + var(--safe-area-top));
|
||||
left: calc(0.5rem + var(--safe-area-left));
|
||||
right: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
max-width: calc(100vw - 1rem - var(--safe-area-left) - var(--safe-area-right));
|
||||
padding: 0.35rem;
|
||||
background: rgba(10, 10, 10, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.header-right::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-right.mobile-collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn-icon-header:not(.btn-sm) {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
/* Compact session tabs — .tabs-two-rows override needed to match
|
||||
@@ -355,7 +409,8 @@ html.mobile-init .file-browser-panel {
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
z-index: 200;
|
||||
contain: none;
|
||||
z-index: 1200;
|
||||
}
|
||||
|
||||
/* iOS safe area adjustment for fixed header - header extends into notch area */
|
||||
@@ -391,8 +446,49 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
|
||||
.header-right {
|
||||
position: fixed;
|
||||
top: calc(40px + var(--safe-area-top));
|
||||
left: calc(0.3rem + var(--safe-area-left));
|
||||
right: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 0.2rem;
|
||||
gap: 0.1rem;
|
||||
max-width: calc(100vw - 0.6rem - var(--safe-area-left) - var(--safe-area-right));
|
||||
padding: 0.25rem;
|
||||
background: rgba(10, 10, 10, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
border-left: none;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.header-right::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-right.mobile-collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-header-utility-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
margin: -6px 0.15rem -6px 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: var(--text-muted);
|
||||
order: -1;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex-shrink: 0;
|
||||
border-left: none;
|
||||
}
|
||||
@@ -445,7 +541,18 @@ html.mobile-init .file-browser-panel {
|
||||
background: rgba(239, 68, 68, 0.25);
|
||||
border-color: rgba(239, 68, 68, 0.6);
|
||||
color: #ef4444;
|
||||
animation: voice-pulse 1.2s ease-in-out infinite;
|
||||
animation: mobile-voice-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes mobile-voice-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.35);
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
50% {
|
||||
box-shadow: inset 0 0 0 2px rgba(239, 68, 68, 0.75);
|
||||
background: rgba(239, 68, 68, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile app settings gear in toolbar - far right */
|
||||
@@ -491,7 +598,8 @@ html.mobile-init .file-browser-panel {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
bottom: auto;
|
||||
height: var(--app-height, 100dvh);
|
||||
}
|
||||
|
||||
/* Ultra-compact session tabs — .tabs-two-rows override needed to match
|
||||
@@ -548,10 +656,10 @@ html.mobile-init .file-browser-panel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.5rem;
|
||||
font-size: 0.65rem;
|
||||
line-height: 1;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-left: auto;
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -617,6 +725,10 @@ html.mobile-init .file-browser-panel {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar-center .btn-toolbar.btn-voice {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -798,9 +910,17 @@ html.mobile-init .file-browser-panel {
|
||||
|
||||
.btn-toolbar.btn-shell {
|
||||
flex: 0 0 auto;
|
||||
min-width: fit-content;
|
||||
min-width: 54px;
|
||||
width: 54px;
|
||||
white-space: nowrap;
|
||||
padding: 0 10px !important;
|
||||
padding: 0 8px !important;
|
||||
overflow: hidden;
|
||||
font-size: 0 !important;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-shell::after {
|
||||
content: "Shell";
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
/* Mobile case button - visible on mobile */
|
||||
@@ -843,6 +963,24 @@ html.mobile-init .file-browser-panel {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 374px) {
|
||||
.toolbar {
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-left .toolbar-group,
|
||||
.toolbar-left .toolbar-group:first-child {
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.btn-case-settings-mobile {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile case settings popover */
|
||||
.case-settings-popover-mobile {
|
||||
position: fixed;
|
||||
@@ -1243,6 +1381,16 @@ html.mobile-init .file-browser-panel {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.history-show-more {
|
||||
display: block;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.welcome-ralph-link {
|
||||
display: block;
|
||||
margin: 0.75rem auto 0;
|
||||
}
|
||||
|
||||
.welcome-hint {
|
||||
font-size: 0.7rem;
|
||||
margin-top: 0.75rem;
|
||||
|
||||
@@ -326,7 +326,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
|
||||
this.loadTunnelStatus();
|
||||
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? false;
|
||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
|
||||
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
|
||||
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
||||
// Claude CLI settings
|
||||
@@ -1603,6 +1603,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
imageWatcherEnabled: false,
|
||||
ralphTrackerEnabled: false,
|
||||
tabTwoRows: false,
|
||||
cjkInputEnabled: false,
|
||||
};
|
||||
}
|
||||
// Desktop defaults - rely on ?? operators in apply functions
|
||||
@@ -1643,9 +1644,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
applyHeaderVisibilitySettings() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
const showFontControls = settings.showFontControls ?? defaults.showFontControls ?? false;
|
||||
const showSystemStats = settings.showSystemStats ?? defaults.showSystemStats ?? true;
|
||||
const showTokenCount = settings.showTokenCount ?? defaults.showTokenCount ?? true;
|
||||
const compactHeader = MobileDetection.getDeviceType() !== 'desktop';
|
||||
const showFontControls = compactHeader ? false : (settings.showFontControls ?? defaults.showFontControls ?? false);
|
||||
const showSystemStats = compactHeader ? false : (settings.showSystemStats ?? defaults.showSystemStats ?? true);
|
||||
const showTokenCount = compactHeader ? false : (settings.showTokenCount ?? defaults.showTokenCount ?? true);
|
||||
|
||||
const fontControlsEl = document.querySelector('.header-font-controls');
|
||||
const systemStatsEl = document.getElementById('headerSystemStats');
|
||||
@@ -1692,6 +1694,70 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
toggleMobileHeaderUtilities() {
|
||||
const tray = document.getElementById('headerRight');
|
||||
const toggle = document.getElementById('mobileHeaderUtilityToggle');
|
||||
if (!tray) return;
|
||||
|
||||
const expanded = tray.classList.toggle('mobile-collapsed') === false;
|
||||
if (toggle) {
|
||||
toggle.classList.toggle('active', expanded);
|
||||
toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||
}
|
||||
},
|
||||
|
||||
handleMobileHeaderUtilityToggle(event) {
|
||||
if (event) {
|
||||
event.preventDefault?.();
|
||||
event.stopPropagation?.();
|
||||
|
||||
const now = Date.now();
|
||||
if ((event.type === 'click' || event.type === 'touchend') && this._lastMobileHeaderUtilityPointerAt) {
|
||||
if (now - this._lastMobileHeaderUtilityPointerAt < 500) return;
|
||||
}
|
||||
if (event.type === 'click' && this._lastMobileHeaderUtilityTouchAt) {
|
||||
if (now - this._lastMobileHeaderUtilityTouchAt < 500) return;
|
||||
}
|
||||
if (event.type === 'touchend') {
|
||||
this._lastMobileHeaderUtilityTouchAt = now;
|
||||
}
|
||||
if (event.type === 'pointerup') {
|
||||
this._lastMobileHeaderUtilityPointerAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
this.toggleMobileHeaderUtilities();
|
||||
},
|
||||
|
||||
bindMobileHeaderUtilityToggle() {
|
||||
const toggle = document.getElementById('mobileHeaderUtilityToggle');
|
||||
if (!toggle || this._mobileHeaderUtilityToggleEl === toggle) return;
|
||||
|
||||
if (this._mobileHeaderUtilityToggleEl && this._mobileHeaderUtilityToggleHandler) {
|
||||
this._mobileHeaderUtilityToggleEl.removeEventListener('click', this._mobileHeaderUtilityToggleHandler);
|
||||
this._mobileHeaderUtilityToggleEl.removeEventListener('touchend', this._mobileHeaderUtilityToggleHandler);
|
||||
this._mobileHeaderUtilityToggleEl.removeEventListener('pointerup', this._mobileHeaderUtilityToggleHandler);
|
||||
}
|
||||
|
||||
this._mobileHeaderUtilityToggleEl = toggle;
|
||||
this._mobileHeaderUtilityToggleHandler = (event) => this.handleMobileHeaderUtilityToggle(event);
|
||||
toggle.addEventListener('click', this._mobileHeaderUtilityToggleHandler);
|
||||
toggle.addEventListener('touchend', this._mobileHeaderUtilityToggleHandler, { passive: false });
|
||||
toggle.addEventListener('pointerup', this._mobileHeaderUtilityToggleHandler);
|
||||
},
|
||||
|
||||
closeMobileHeaderUtilities() {
|
||||
const tray = document.getElementById('headerRight');
|
||||
const toggle = document.getElementById('mobileHeaderUtilityToggle');
|
||||
if (!tray || tray.classList.contains('mobile-collapsed')) return;
|
||||
|
||||
tray.classList.add('mobile-collapsed');
|
||||
if (toggle) {
|
||||
toggle.classList.remove('active');
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
},
|
||||
|
||||
applyTabWrapSettings() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
|
||||
@@ -107,11 +107,11 @@ textarea:focus-visible {
|
||||
caret-color: transparent !important;
|
||||
}
|
||||
.touch-device .xterm .xterm-helper-textarea {
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
left: var(--xterm-helper-left, 0px) !important;
|
||||
top: var(--xterm-helper-top, 0px) !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
z-index: -1 !important;
|
||||
z-index: 0 !important;
|
||||
font-size: 16px !important; /* prevent iOS auto-zoom on focus */
|
||||
}
|
||||
|
||||
@@ -8674,6 +8674,33 @@ kbd {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.touch-device #cjkInput.cjk-input-visible {
|
||||
position: fixed;
|
||||
left: var(--safe-area-left);
|
||||
right: var(--safe-area-right);
|
||||
bottom: calc(var(--safe-area-bottom) + 40px);
|
||||
z-index: 52;
|
||||
display: block;
|
||||
min-height: 44px;
|
||||
max-height: 96px;
|
||||
border: 1px solid rgba(80, 120, 190, 0.55);
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
background: #101827;
|
||||
color: #f3f4f6;
|
||||
box-shadow: 0 -8px 20px rgba(0, 0, 0, 0.35);
|
||||
transition: transform 0.15s ease-out;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.touch-device.keyboard-visible #cjkInput.cjk-input-visible {
|
||||
bottom: calc(var(--safe-area-bottom) + 84px);
|
||||
}
|
||||
|
||||
body.touch-device.cjk-input-visible .main {
|
||||
padding-bottom: calc(84px + var(--safe-area-bottom));
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Orchestrator Panel
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
@@ -262,15 +262,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
this._localEchoOverlay = new LocalEchoOverlay(this.terminal);
|
||||
if (MobileDetection.isTouchDevice()) {
|
||||
this.terminal.onCursorMove(() => this._syncMobileHelperTextareaToCursor());
|
||||
this.terminal.onRender(() => this._syncMobileHelperTextareaToCursor());
|
||||
}
|
||||
|
||||
// CJK IME input — textarea in index.html, just wire up send
|
||||
this._cjkInput = null;
|
||||
if (typeof CjkInput !== 'undefined') {
|
||||
this._cjkInput = CjkInput.init({
|
||||
send: (text) => {
|
||||
if (this.activeSessionId) {
|
||||
this._sendInputAsync(this.activeSessionId, text);
|
||||
}
|
||||
this._handleCjkInput(text);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -393,7 +395,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
// routes back to the terminal. Without this, a tap on the terminal area
|
||||
// consumes the touch event but xterm's textarea never regains focus.
|
||||
if (!didScroll && this.terminal) {
|
||||
this.terminal.focus();
|
||||
const cjkInput = document.getElementById('cjkInput');
|
||||
if (cjkInput?.classList.contains('cjk-input-visible')) {
|
||||
cjkInput.focus();
|
||||
} else {
|
||||
this._syncMobileHelperTextareaToCursor();
|
||||
this.terminal.focus();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
@@ -1468,12 +1476,80 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._localEchoOverlay.clear();
|
||||
this._localEchoEnabled = false;
|
||||
} else {
|
||||
// Claude Code: scan for ❯ prompt character
|
||||
this._localEchoOverlay.setPrompt({ type: 'character', char: '\u276f', offset: 2 });
|
||||
// Codex/Claude-style TUIs usually expose a ❯ prompt. During active
|
||||
// redraws or compact mobile layouts that marker may not be present in
|
||||
// the viewport, while xterm's cursor still marks the editable input
|
||||
// position. Fall back to cursor coordinates so phone typing appears at
|
||||
// the terminal cursor instead of disappearing into pending state.
|
||||
this._localEchoOverlay.setPrompt({
|
||||
type: 'custom',
|
||||
offset: 0,
|
||||
find: (terminal) => {
|
||||
try {
|
||||
const buf = terminal.buffer.active;
|
||||
for (let row = terminal.rows - 1; row >= 0; row--) {
|
||||
const line = buf.getLine(buf.viewportY + row);
|
||||
if (!line) continue;
|
||||
const text = line.translateToString(true);
|
||||
const idx = text.lastIndexOf('\u276f');
|
||||
if (idx >= 0) return { row, col: idx + 2 };
|
||||
}
|
||||
return {
|
||||
row: Math.max(0, Math.min(terminal.rows - 1, buf.cursorY)),
|
||||
col: Math.max(0, Math.min(terminal.cols - 1, buf.cursorX)),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_handleCjkInput(text) {
|
||||
if (!this.activeSessionId) return;
|
||||
const sessionId = this.activeSessionId;
|
||||
const session = this.sessions.get(sessionId);
|
||||
const useLocalEcho = !!(this._localEchoEnabled && this._localEchoOverlay && session?.mode !== 'shell');
|
||||
if (!useLocalEcho) {
|
||||
this._sendInputAsync(sessionId, text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (text === '\x7f') {
|
||||
const source = this._localEchoOverlay.removeChar();
|
||||
if (source === 'flushed') this._sendInputAsync(sessionId, text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (/[\r\n]+$/.test(text)) {
|
||||
const committed = text.replace(/[\r\n]+$/g, '');
|
||||
if (committed) this._localEchoOverlay.appendText(committed);
|
||||
const pending = this._localEchoOverlay.pendingText || '';
|
||||
this._localEchoOverlay.clear();
|
||||
this._localEchoOverlay.suppressBufferDetection();
|
||||
this._flushedOffsets?.delete(sessionId);
|
||||
this._flushedTexts?.delete(sessionId);
|
||||
if (pending) this._sendInputAsync(sessionId, pending);
|
||||
setTimeout(() => this._sendInputAsync(sessionId, '\r'), pending ? 80 : 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.length === 1 && text.charCodeAt(0) < 32) {
|
||||
const pending = this._localEchoOverlay.pendingText || '';
|
||||
this._localEchoOverlay.clear();
|
||||
this._localEchoOverlay.suppressBufferDetection();
|
||||
this._flushedOffsets?.delete(sessionId);
|
||||
this._flushedTexts?.delete(sessionId);
|
||||
if (pending) this._sendInputAsync(sessionId, pending);
|
||||
this._sendInputAsync(sessionId, text);
|
||||
return;
|
||||
}
|
||||
|
||||
this._localEchoOverlay.appendText(text);
|
||||
},
|
||||
|
||||
/**
|
||||
* Flush pending writes to terminal, processing DEC 2026 sync markers.
|
||||
* Strips markers and writes content atomically within a single frame.
|
||||
@@ -1849,6 +1925,23 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
_syncMobileHelperTextareaToCursor() {
|
||||
if (!MobileDetection.isTouchDevice() || !this.terminal?.element) return;
|
||||
try {
|
||||
const xtermEl = this.terminal.element;
|
||||
const cursor = this.terminal.element.querySelector('.xterm-cursor');
|
||||
const screen = this.terminal.element.querySelector('.xterm-screen');
|
||||
if (!(xtermEl instanceof HTMLElement) || !(cursor instanceof HTMLElement) || !(screen instanceof HTMLElement)) return;
|
||||
const cursorRect = cursor.getBoundingClientRect();
|
||||
const screenRect = screen.getBoundingClientRect();
|
||||
if (!cursorRect.width && !cursorRect.height) return;
|
||||
const left = Math.max(0, Math.round(cursorRect.left - screenRect.left));
|
||||
const top = Math.max(0, Math.round(cursorRect.top - screenRect.top));
|
||||
xtermEl.style.setProperty('--xterm-helper-left', `${left}px`);
|
||||
xtermEl.style.setProperty('--xterm-helper-top', `${top}px`);
|
||||
} catch {}
|
||||
},
|
||||
|
||||
increaseFontSize() {
|
||||
const current = this.terminal.options.fontSize || 14;
|
||||
this.setFontSize(Math.min(current + 2, 24));
|
||||
@@ -1917,10 +2010,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
// clear the terminal for the same dimensions (which would blank the screen
|
||||
// without a subsequent Ink redraw to repaint it).
|
||||
this._lastResizeDims = { cols: dims.cols, rows: dims.rows };
|
||||
const viewportType =
|
||||
typeof MobileDetection !== 'undefined' && MobileDetection.getDeviceType
|
||||
? MobileDetection.getDeviceType()
|
||||
: window.innerWidth < 430
|
||||
? 'mobile'
|
||||
: window.innerWidth < 768
|
||||
? 'tablet'
|
||||
: 'desktop';
|
||||
// Fast path: WebSocket resize
|
||||
if (!options.forceHttp && this._wsReady && this._wsSessionId === sessionId) {
|
||||
try {
|
||||
this._ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows }));
|
||||
this._ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: viewportType }));
|
||||
return changed;
|
||||
} catch {
|
||||
// Fall through to HTTP POST
|
||||
@@ -1929,7 +2030,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
await fetch(`/api/sessions/${sessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dims),
|
||||
body: JSON.stringify({ ...dims, viewportType }),
|
||||
});
|
||||
return changed;
|
||||
},
|
||||
|
||||
@@ -677,10 +677,14 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/resize', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { cols, rows } = parseBody(ResizeSchema, req.body);
|
||||
const { cols, rows, viewportType } = parseBody(ResizeSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
session.resize(cols, rows);
|
||||
if (viewportType) {
|
||||
session.resize(cols, rows, { viewportType });
|
||||
} else {
|
||||
session.resize(cols, rows);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
|
||||
@@ -126,7 +126,12 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
||||
msg.r >= 1 &&
|
||||
msg.r <= 200
|
||||
) {
|
||||
session.resize(msg.c, msg.r);
|
||||
const viewportType = msg.v === 'mobile' || msg.v === 'tablet' || msg.v === 'desktop' ? msg.v : undefined;
|
||||
if (viewportType) {
|
||||
session.resize(msg.c, msg.r, { viewportType });
|
||||
} else {
|
||||
session.resize(msg.c, msg.r);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
|
||||
@@ -161,6 +161,7 @@ export const RunPromptSchema = z.object({
|
||||
export const ResizeSchema = z.object({
|
||||
cols: z.number().int().min(1).max(500),
|
||||
rows: z.number().int().min(1).max(200),
|
||||
viewportType: z.enum(['mobile', 'tablet', 'desktop']).optional(),
|
||||
});
|
||||
|
||||
// ========== Case Routes ==========
|
||||
|
||||
Reference in New Issue
Block a user