Compare commits

..

9 Commits

Author SHA1 Message Date
Haileyesus
a187a21ef2 feat(shell): add Ctrl+C button to mobile terminal shortcuts
Add a Ctrl+C key to the right end of the mobile shortcuts bar so users
can interrupt the foreground process without a physical keyboard. Sends
\x03 (ETX), which is the same interrupt sequence on Windows and Linux.
2026-06-27 17:41:22 +03:00
Haileyesus
7d925e95ab fix(shell): add inertial scrolling to mobile terminal
xterm scrolls the viewport 1:1 with the finger and prevents native
scrolling, so the terminal stopped the instant the finger lifted with
no momentum — making it tedious to scroll long output on mobile.

Track finger velocity during a one-finger drag and, on release, keep
scrolling the viewport with friction until it slows to a stop or hits
the buffer bounds. Inertia is cancelled on a new touch, selection,
pinch, or dispose so it never fights the user. Coasting behaviour is
tunable via the SCROLL_INERTIA_* constants.
2026-06-27 17:31:54 +03:00
Haileyesus
fb06a0ce20 fix(shell): resize terminal when mobile keyboard opens
On Chrome/Android the on-screen keyboard overlays content by default
(interactive-widget=resizes-visual), so the layout viewport kept its
full height when the keyboard appeared. The terminal stayed at its
pre-keyboard size, leaving dead space above the keyboard and a stale
scroll geometry.

Set interactive-widget=resizes-content so Android shrinks the layout
viewport on keyboard open. The fixed inve
the keyboard and the existing ResizeObserver re-fits xterm to the new
height. iOS is unaffected (interactive-widget is ignored there; the
VisualViewport --keyboard-height path still applies).
2026-06-27 17:07:22 +03:00
Haileyesus
da27dd93db fix(app): prevent mobile document overscroll 2026-06-26 19:33:05 +03:00
Haileyesus
5459d7c60e fix(shell): add copy/select-all buttons and zoom in and out 2026-06-26 19:06:09 +03:00
Simos Mikelatos
645914f2c8 Merge branch 'main' into fix/mobile-shell-selection-auth-flow 2026-06-26 16:06:59 +02:00
Haileyesus
c7a0891f56 fix(shell): remove mobile auth url controls 2026-06-26 15:51:10 +03:00
Haileyesus
241ed1da54 fix(shell): support mobile terminal text selection 2026-06-26 15:38:11 +03:00
Haileyesus
ee002fc3f7 fix(shell): keep c input during auth URL flow 2026-06-26 14:25:55 +03:00
14 changed files with 1105 additions and 330 deletions

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
<title>CloudCLI UI</title> <title>CloudCLI UI</title>
<!-- PWA Manifest --> <!-- PWA Manifest -->

View File

@@ -1,77 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { QuestionAnswerContent } from './QuestionAnswerContent';
// Regression coverage for the chat-interface crash where an AskUserQuestion
// payload loaded from a session transcript arrives with a non-array `questions`
// or a question missing its `options` array. Rendering must degrade gracefully
// instead of throwing "TypeError: e.map is not a function".
test('renders without throwing when questions is a non-array value', () => {
assert.doesNotThrow(() => {
renderToStaticMarkup(
React.createElement(QuestionAnswerContent, {
// Malformed: object instead of an array
questions: { 0: { question: 'q?', options: [{ label: 'a' }] } } as never,
answers: {},
}),
);
});
});
test('renders without throwing when a question is missing options[]', () => {
assert.doesNotThrow(() => {
renderToStaticMarkup(
React.createElement(QuestionAnswerContent, {
questions: [{ question: 'Pick one?', header: 'H' } as never],
answers: { 'Pick one?': 'X' },
}),
);
});
});
test('renders without throwing when options[] contains malformed entries', () => {
assert.doesNotThrow(() => {
renderToStaticMarkup(
React.createElement(QuestionAnswerContent, {
questions: [{ question: 'Pick one?', options: [null, 'oops', { label: 'A' }] } as never],
answers: { 'Pick one?': 'A, Custom' },
}),
);
});
});
test('renders without throwing when a questions entry is null/non-object', () => {
assert.doesNotThrow(() => {
renderToStaticMarkup(
React.createElement(QuestionAnswerContent, {
questions: [null, 'oops', { question: 'Ok?', options: [{ label: 'A' }] }] as never,
answers: {},
}),
);
});
});
test('renders without throwing when an answer is a non-string value', () => {
assert.doesNotThrow(() => {
renderToStaticMarkup(
React.createElement(QuestionAnswerContent, {
questions: [{ question: 'Pick one?', options: [{ label: 'A' }] }],
// Malformed: answer is an object instead of the expected string
answers: { 'Pick one?': { unexpected: true } } as never,
}),
);
});
});
test('still renders a well-formed question + answer', () => {
const html = renderToStaticMarkup(
React.createElement(QuestionAnswerContent, {
questions: [{ question: 'Pick one?', header: 'H', options: [{ label: 'A' }, { label: 'B' }] }],
answers: { 'Pick one?': 'A' },
}),
);
assert.ok(html.includes('Pick one?'));
});

View File

@@ -15,11 +15,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
}) => { }) => {
const [expandedIdx, setExpandedIdx] = useState<number | null>(null); const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
// Tool inputs are runtime data loaded from session transcripts and may be if (!questions || questions.length === 0) {
// malformed (e.g. `questions` arriving as a non-array). Guard with
// Array.isArray so a single bad payload can't crash the whole chat view
// with "e.map is not a function".
if (!Array.isArray(questions) || questions.length === 0) {
return null; return null;
} }
@@ -28,23 +24,11 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
return ( return (
<div className={`space-y-2 ${className}`}> <div className={`space-y-2 ${className}`}>
{questions.map((rawQuestion, idx) => { {questions.map((q, idx) => {
// Entries come from session transcripts and may be malformed; skip
// anything that isn't a proper question object with a string prompt.
if (!rawQuestion || typeof rawQuestion !== 'object' || typeof rawQuestion.question !== 'string') {
return null;
}
const q = rawQuestion;
const answer = answers?.[q.question]; const answer = answers?.[q.question];
// `answer` may be a non-string (or absent) in malformed payloads. const answerLabels = answer ? answer.split(', ') : [];
const answerLabels = typeof answer === 'string' ? answer.split(', ') : [];
const skipped = !answer; const skipped = !answer;
const isExpanded = expandedIdx === idx; const isExpanded = expandedIdx === idx;
// `options` is typed as an array but comes from untrusted runtime data;
// keep only valid entries so `.some`/`.map` below never throw.
const options = Array.isArray(q.options)
? q.options.filter((opt) => opt && typeof opt === 'object' && typeof opt.label === 'string')
: [];
return ( return (
<div <div
@@ -90,7 +74,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
{!isExpanded && answerLabels.length > 0 && ( {!isExpanded && answerLabels.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1"> <div className="mt-1.5 flex flex-wrap gap-1">
{answerLabels.map((lbl) => { {answerLabels.map((lbl) => {
const isCustom = !options.some(o => o.label === lbl); const isCustom = !q.options.some(o => o.label === lbl);
return ( return (
<span <span
key={lbl} key={lbl}
@@ -126,7 +110,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
{isExpanded && ( {isExpanded && (
<div className="border-t border-gray-100 px-3 pb-2.5 pt-0.5 dark:border-gray-700/40"> <div className="border-t border-gray-100 px-3 pb-2.5 pt-0.5 dark:border-gray-700/40">
<div className="ml-6.5 space-y-1"> <div className="ml-6.5 space-y-1">
{options.map((opt) => { {q.options.map((opt) => {
const wasSelected = answerLabels.includes(opt.label); const wasSelected = answerLabels.includes(opt.label);
return ( return (
<div <div
@@ -164,7 +148,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
); );
})} })}
{answerLabels.filter(lbl => !options.some(o => o.label === lbl)).map(lbl => ( {answerLabels.filter(lbl => !q.options.some(o => o.label === lbl)).map(lbl => (
<div <div
key={lbl} key={lbl}
className="flex items-start gap-2 rounded-lg border border-blue-200/60 bg-blue-50/80 px-2.5 py-1.5 text-[12px] dark:border-blue-800/40 dark:bg-blue-900/20" className="flex items-start gap-2 rounded-lg border border-blue-200/60 bg-blue-50/80 px-2.5 py-1.5 text-[12px] dark:border-blue-800/40 dark:bg-blue-900/20"

View File

@@ -1,6 +1,5 @@
import type { ITerminalOptions } from '@xterm/xterm'; import type { ITerminalOptions } from '@xterm/xterm';
export const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device';
export const SHELL_RESTART_DELAY_MS = 200; export const SHELL_RESTART_DELAY_MS = 200;
export const TERMINAL_INIT_DELAY_MS = 100; export const TERMINAL_INIT_DELAY_MS = 100;
export const TERMINAL_RESIZE_DELAY_MS = 50; export const TERMINAL_RESIZE_DELAY_MS = 50;

View File

@@ -24,7 +24,6 @@ type UseShellConnectionOptions = {
autoConnect: boolean; autoConnect: boolean;
closeSocket: () => void; closeSocket: () => void;
clearTerminalScreen: () => void; clearTerminalScreen: () => void;
setAuthUrl: (nextAuthUrl: string) => void;
onOutputRef?: MutableRefObject<(() => void) | null>; onOutputRef?: MutableRefObject<(() => void) | null>;
}; };
@@ -49,7 +48,6 @@ export function useShellConnection({
autoConnect, autoConnect,
closeSocket, closeSocket,
clearTerminalScreen, clearTerminalScreen,
setAuthUrl,
onOutputRef, onOutputRef,
}: UseShellConnectionOptions): UseShellConnectionResult { }: UseShellConnectionOptions): UseShellConnectionResult {
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
@@ -100,14 +98,8 @@ export function useShellConnection({
return; return;
} }
if (message.type === 'auth_url' || message.type === 'url_open') {
const nextAuthUrl = typeof message.url === 'string' ? message.url : '';
if (nextAuthUrl) {
setAuthUrl(nextAuthUrl);
}
}
}, },
[handleProcessCompletion, onOutputRef, setAuthUrl, terminalRef], [handleProcessCompletion, onOutputRef, terminalRef],
); );
const connectWebSocket = useCallback( const connectWebSocket = useCallback(
@@ -133,7 +125,6 @@ export function useShellConnection({
setIsConnected(true); setIsConnected(true);
setIsConnecting(false); setIsConnecting(false);
connectingRef.current = false; connectingRef.current = false;
setAuthUrl('');
window.setTimeout(() => { window.setTimeout(() => {
const currentTerminal = terminalRef.current; const currentTerminal = terminalRef.current;
@@ -196,7 +187,6 @@ export function useShellConnection({
isPlainShellRef, isPlainShellRef,
selectedProjectRef, selectedProjectRef,
selectedSessionRef, selectedSessionRef,
setAuthUrl,
terminalRef, terminalRef,
wsRef, wsRef,
], ],
@@ -225,8 +215,7 @@ export function useShellConnection({
setIsConnecting(false); setIsConnecting(false);
connectingRef.current = false; connectingRef.current = false;
forceRestartOnInitRef.current = false; forceRestartOnInitRef.current = false;
setAuthUrl(''); }, [clearTerminalScreen, closeSocket]);
}, [clearTerminalScreen, closeSocket, setAuthUrl]);
useEffect(() => { useEffect(() => {
if ( if (

View File

@@ -1,8 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import type { FitAddon } from '@xterm/addon-fit'; import type { FitAddon } from '@xterm/addon-fit';
import type { Terminal } from '@xterm/xterm'; import type { Terminal } from '@xterm/xterm';
import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types'; import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types';
import { copyTextToClipboard } from '../../../utils/clipboard';
import { useShellConnection } from './useShellConnection'; import { useShellConnection } from './useShellConnection';
import { useShellTerminal } from './useShellTerminal'; import { useShellTerminal } from './useShellTerminal';
@@ -22,15 +23,11 @@ export function useShellRuntime({
const fitAddonRef = useRef<FitAddon | null>(null); const fitAddonRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const [authUrl, setAuthUrl] = useState('');
const [authUrlVersion, setAuthUrlVersion] = useState(0);
const selectedProjectRef = useRef(selectedProject); const selectedProjectRef = useRef(selectedProject);
const selectedSessionRef = useRef(selectedSession); const selectedSessionRef = useRef(selectedSession);
const initialCommandRef = useRef(initialCommand); const initialCommandRef = useRef(initialCommand);
const isPlainShellRef = useRef(isPlainShell); const isPlainShellRef = useRef(isPlainShell);
const onProcessCompleteRef = useRef(onProcessComplete); const onProcessCompleteRef = useRef(onProcessComplete);
const authUrlRef = useRef('');
const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null); const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null);
// Keep mutable values in refs so websocket handlers always read current data. // Keep mutable values in refs so websocket handlers always read current data.
@@ -42,12 +39,6 @@ export function useShellRuntime({
onProcessCompleteRef.current = onProcessComplete; onProcessCompleteRef.current = onProcessComplete;
}, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]); }, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]);
const setCurrentAuthUrl = useCallback((nextAuthUrl: string) => {
authUrlRef.current = nextAuthUrl;
setAuthUrl(nextAuthUrl);
setAuthUrlVersion((previous) => previous + 1);
}, []);
const closeSocket = useCallback(() => { const closeSocket = useCallback(() => {
const activeSocket = wsRef.current; const activeSocket = wsRef.current;
if (!activeSocket) { if (!activeSocket) {
@@ -64,32 +55,6 @@ export function useShellRuntime({
wsRef.current = null; wsRef.current = null;
}, []); }, []);
const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => {
if (!url) {
return false;
}
const popup = window.open(url, '_blank');
if (popup) {
try {
popup.opener = null;
} catch {
// Ignore cross-origin restrictions when trying to null opener.
}
return true;
}
return false;
}, []);
const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => {
if (!url) {
return false;
}
return copyTextToClipboard(url);
}, []);
const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({ const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({
terminalContainerRef, terminalContainerRef,
terminalRef, terminalRef,
@@ -98,10 +63,6 @@ export function useShellRuntime({
selectedProject, selectedProject,
minimal, minimal,
isRestarting, isRestarting,
initialCommandRef,
isPlainShellRef,
authUrlRef,
copyAuthUrlToClipboard,
closeSocket, closeSocket,
}); });
@@ -118,7 +79,6 @@ export function useShellRuntime({
autoConnect, autoConnect,
closeSocket, closeSocket,
clearTerminalScreen, clearTerminalScreen,
setAuthUrl: setCurrentAuthUrl,
onOutputRef, onOutputRef,
}); });
@@ -156,11 +116,7 @@ export function useShellRuntime({
isConnected, isConnected,
isInitialized, isInitialized,
isConnecting, isConnecting,
authUrl,
authUrlVersion,
connectToShell, connectToShell,
disconnectFromShell, disconnectFromShell,
openAuthUrlInBrowser,
copyAuthUrlToClipboard,
}; };
} }

View File

@@ -4,15 +4,18 @@ import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links'; import { WebLinksAddon } from '@xterm/addon-web-links';
import { WebglAddon } from '@xterm/addon-webgl'; import { WebglAddon } from '@xterm/addon-webgl';
import { Terminal } from '@xterm/xterm'; import { Terminal } from '@xterm/xterm';
import type { Project } from '../../../types/app'; import type { Project } from '../../../types/app';
import { copyTextToClipboard } from '../../../utils/clipboard';
import { import {
CODEX_DEVICE_AUTH_URL,
TERMINAL_INIT_DELAY_MS, TERMINAL_INIT_DELAY_MS,
TERMINAL_OPTIONS, TERMINAL_OPTIONS,
TERMINAL_RESIZE_DELAY_MS, TERMINAL_RESIZE_DELAY_MS,
} from '../constants/constants'; } from '../constants/constants';
import { copyTextToClipboard } from '../../../utils/clipboard'; import {
import { isCodexLoginCommand } from '../utils/auth'; installMobileTerminalSelection,
type MobileTerminalSelectionManager,
} from '../utils/mobileTerminalSelection';
import { sendSocketMessage } from '../utils/socket'; import { sendSocketMessage } from '../utils/socket';
import { ensureXtermFocusStyles } from '../utils/terminalStyles'; import { ensureXtermFocusStyles } from '../utils/terminalStyles';
@@ -24,10 +27,6 @@ type UseShellTerminalOptions = {
selectedProject: Project | null | undefined; selectedProject: Project | null | undefined;
minimal: boolean; minimal: boolean;
isRestarting: boolean; isRestarting: boolean;
initialCommandRef: MutableRefObject<string | null | undefined>;
isPlainShellRef: MutableRefObject<boolean>;
authUrlRef: MutableRefObject<string>;
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
closeSocket: () => void; closeSocket: () => void;
}; };
@@ -45,14 +44,11 @@ export function useShellTerminal({
selectedProject, selectedProject,
minimal, minimal,
isRestarting, isRestarting,
initialCommandRef,
isPlainShellRef,
authUrlRef,
copyAuthUrlToClipboard,
closeSocket, closeSocket,
}: UseShellTerminalOptions): UseShellTerminalResult { }: UseShellTerminalOptions): UseShellTerminalResult {
const [isInitialized, setIsInitialized] = useState(false); const [isInitialized, setIsInitialized] = useState(false);
const resizeTimeoutRef = useRef<number | null>(null); const resizeTimeoutRef = useRef<number | null>(null);
const mobileSelectionRef = useRef<MobileTerminalSelectionManager | null>(null);
const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || ''; const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || '';
const hasSelectedProject = Boolean(selectedProject); const hasSelectedProject = Boolean(selectedProject);
@@ -70,6 +66,11 @@ export function useShellTerminal({
}, [terminalRef]); }, [terminalRef]);
const disposeTerminal = useCallback(() => { const disposeTerminal = useCallback(() => {
if (mobileSelectionRef.current) {
mobileSelectionRef.current.dispose();
mobileSelectionRef.current = null;
}
if (terminalRef.current) { if (terminalRef.current) {
terminalRef.current.dispose(); terminalRef.current.dispose();
terminalRef.current = null; terminalRef.current = null;
@@ -80,7 +81,8 @@ export function useShellTerminal({
}, [fitAddonRef, terminalRef]); }, [fitAddonRef, terminalRef]);
useEffect(() => { useEffect(() => {
if (!terminalContainerRef.current || !hasSelectedProject || isRestarting || terminalRef.current) { const terminalContainer = terminalContainerRef.current;
if (!terminalContainer || !hasSelectedProject || isRestarting || terminalRef.current) {
return; return;
} }
@@ -102,7 +104,28 @@ export function useShellTerminal({
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback'); console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
} }
nextTerminal.open(terminalContainerRef.current); nextTerminal.open(terminalContainer);
mobileSelectionRef.current = installMobileTerminalSelection(
nextTerminal,
terminalContainer,
{
onFontSizeChange: (fontSize) => {
nextTerminal.options.fontSize = fontSize;
const currentFitAddon = fitAddonRef.current;
if (currentFitAddon) {
currentFitAddon.fit();
sendSocketMessage(wsRef.current, {
type: 'resize',
cols: nextTerminal.cols,
rows: nextTerminal.rows,
});
} else {
nextTerminal.refresh(0, nextTerminal.rows - 1);
}
},
},
);
const copyTerminalSelection = async () => { const copyTerminalSelection = async () => {
const selection = nextTerminal.getSelection(); const selection = nextTerminal.getSelection();
@@ -133,29 +156,9 @@ export function useShellTerminal({
void copyTextToClipboard(selection); void copyTextToClipboard(selection);
}; };
terminalContainerRef.current.addEventListener('copy', handleTerminalCopy); terminalContainer.addEventListener('copy', handleTerminalCopy);
nextTerminal.attachCustomKeyEventHandler((event) => { nextTerminal.attachCustomKeyEventHandler((event) => {
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
? CODEX_DEVICE_AUTH_URL
: authUrlRef.current;
if (
event.type === 'keydown' &&
minimal &&
isPlainShellRef.current &&
activeAuthUrl &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
event.key?.toLowerCase() === 'c'
) {
event.preventDefault();
event.stopPropagation();
void copyAuthUrlToClipboard(activeAuthUrl);
return false;
}
if ( if (
event.type === 'keydown' && event.type === 'keydown' &&
(event.ctrlKey || event.metaKey) && (event.ctrlKey || event.metaKey) &&
@@ -240,10 +243,10 @@ export function useShellTerminal({
}, TERMINAL_RESIZE_DELAY_MS); }, TERMINAL_RESIZE_DELAY_MS);
}); });
resizeObserver.observe(terminalContainerRef.current); resizeObserver.observe(terminalContainer);
return () => { return () => {
terminalContainerRef.current?.removeEventListener('copy', handleTerminalCopy); terminalContainer.removeEventListener('copy', handleTerminalCopy);
resizeObserver.disconnect(); resizeObserver.disconnect();
if (resizeTimeoutRef.current !== null) { if (resizeTimeoutRef.current !== null) {
window.clearTimeout(resizeTimeoutRef.current); window.clearTimeout(resizeTimeoutRef.current);
@@ -254,16 +257,12 @@ export function useShellTerminal({
disposeTerminal(); disposeTerminal();
}; };
}, [ }, [
authUrlRef,
closeSocket, closeSocket,
copyAuthUrlToClipboard,
disposeTerminal, disposeTerminal,
fitAddonRef, fitAddonRef,
initialCommandRef,
isPlainShellRef,
isRestarting, isRestarting,
minimal,
hasSelectedProject, hasSelectedProject,
minimal,
selectedProjectKey, selectedProjectKey,
terminalContainerRef, terminalContainerRef,
terminalRef, terminalRef,

View File

@@ -4,8 +4,6 @@ import type { Terminal } from '@xterm/xterm';
import type { Project, ProjectSession } from '../../../types/app'; import type { Project, ProjectSession } from '../../../types/app';
export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
export type ShellInitMessage = { export type ShellInitMessage = {
type: 'init'; type: 'init';
projectPath: string; projectPath: string;
@@ -54,7 +52,6 @@ export type ShellSharedRefs = {
wsRef: MutableRefObject<WebSocket | null>; wsRef: MutableRefObject<WebSocket | null>;
terminalRef: MutableRefObject<Terminal | null>; terminalRef: MutableRefObject<Terminal | null>;
fitAddonRef: MutableRefObject<FitAddon | null>; fitAddonRef: MutableRefObject<FitAddon | null>;
authUrlRef: MutableRefObject<string>;
selectedProjectRef: MutableRefObject<Project | null | undefined>; selectedProjectRef: MutableRefObject<Project | null | undefined>;
selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>; selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>;
initialCommandRef: MutableRefObject<string | null | undefined>; initialCommandRef: MutableRefObject<string | null | undefined>;
@@ -69,10 +66,6 @@ export type UseShellRuntimeResult = {
isConnected: boolean; isConnected: boolean;
isInitialized: boolean; isInitialized: boolean;
isConnecting: boolean; isConnecting: boolean;
authUrl: string;
authUrlVersion: number;
connectToShell: (options?: { forceRestart?: boolean }) => void; connectToShell: (options?: { forceRestart?: boolean }) => void;
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void; disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
openAuthUrlInBrowser: (url?: string) => boolean;
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
}; };

View File

@@ -1,17 +1,4 @@
import type { ProjectSession } from '../../../types/app'; import type { ProjectSession } from '../../../types/app';
import { CODEX_DEVICE_AUTH_URL } from '../constants/constants';
export function isCodexLoginCommand(command: string | null | undefined): boolean {
return typeof command === 'string' && /\bcodex\s+login\b/i.test(command);
}
export function resolveAuthUrlForDisplay(command: string | null | undefined, authUrl: string): string {
if (isCodexLoginCommand(command)) {
return CODEX_DEVICE_AUTH_URL;
}
return authUrl;
}
export function getSessionDisplayName(session: ProjectSession | null | undefined): string | null { export function getSessionDisplayName(session: ProjectSession | null | undefined): string | null {
if (!session) { if (!session) {

File diff suppressed because it is too large Load Diff

View File

@@ -59,12 +59,8 @@ export default function Shell({
isConnected, isConnected,
isInitialized, isInitialized,
isConnecting, isConnecting,
authUrl,
authUrlVersion,
connectToShell, connectToShell,
disconnectFromShell, disconnectFromShell,
openAuthUrlInBrowser,
copyAuthUrlToClipboard,
} = useShellRuntime({ } = useShellRuntime({
selectedProject, selectedProject,
selectedSession, selectedSession,
@@ -243,15 +239,7 @@ export default function Shell({
if (minimal) { if (minimal) {
return ( return (
<> <>
<ShellMinimalView <ShellMinimalView terminalContainerRef={terminalContainerRef} />
terminalContainerRef={terminalContainerRef}
authUrl={authUrl}
authUrlVersion={authUrlVersion}
initialCommand={initialCommand}
isConnected={isConnected}
openAuthUrlInBrowser={openAuthUrlInBrowser}
copyAuthUrlToClipboard={copyAuthUrlToClipboard}
/>
<TerminalShortcutsPanel <TerminalShortcutsPanel
wsRef={wsRef} wsRef={wsRef}
terminalRef={terminalRef} terminalRef={terminalRef}

View File

@@ -1,45 +1,12 @@
import { useEffect, useMemo, useState } from 'react';
import type { RefObject } from 'react'; import type { RefObject } from 'react';
import type { AuthCopyStatus } from '../../types/types';
import { resolveAuthUrlForDisplay } from '../../utils/auth';
type ShellMinimalViewProps = { type ShellMinimalViewProps = {
terminalContainerRef: RefObject<HTMLDivElement>; terminalContainerRef: RefObject<HTMLDivElement>;
authUrl: string;
authUrlVersion: number;
initialCommand: string | null | undefined;
isConnected: boolean;
openAuthUrlInBrowser: (url: string) => boolean;
copyAuthUrlToClipboard: (url: string) => Promise<boolean>;
}; };
export default function ShellMinimalView({ export default function ShellMinimalView({
terminalContainerRef, terminalContainerRef,
authUrl,
authUrlVersion,
initialCommand,
isConnected,
openAuthUrlInBrowser,
copyAuthUrlToClipboard,
}: ShellMinimalViewProps) { }: ShellMinimalViewProps) {
const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState<AuthCopyStatus>('idle');
const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false);
const displayAuthUrl = useMemo(
() => resolveAuthUrlForDisplay(initialCommand, authUrl),
[authUrl, initialCommand],
);
// Keep auth panel UI state local to minimal mode and reset it when connection/url changes.
useEffect(() => {
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
}, [authUrlVersion, displayAuthUrl, isConnected]);
const hasAuthUrl = Boolean(displayAuthUrl);
const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden;
const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden;
return ( return (
<div className="relative h-full w-full bg-gray-900"> <div className="relative h-full w-full bg-gray-900">
<div <div
@@ -47,67 +14,6 @@ export default function ShellMinimalView({
className="h-full w-full focus:outline-none" className="h-full w-full focus:outline-none"
style={{ outline: 'none' }} style={{ outline: 'none' }}
/> />
{showMobileAuthPanel && (
<div className="absolute inset-x-0 bottom-14 z-20 border-t border-gray-700/80 bg-gray-900/95 p-3 backdrop-blur-sm md:hidden">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-gray-300">Open or copy the login URL:</p>
<button
type="button"
onClick={() => setIsAuthPanelHidden(true)}
className="rounded bg-gray-700 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-gray-100 hover:bg-gray-600"
>
Hide
</button>
</div>
<input
type="text"
value={displayAuthUrl}
readOnly
onClick={(event) => event.currentTarget.select()}
className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
aria-label="Authentication URL"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
openAuthUrlInBrowser(displayAuthUrl);
}}
className="flex-1 rounded bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-700"
>
Open URL
</button>
<button
type="button"
onClick={async () => {
const copied = await copyAuthUrlToClipboard(displayAuthUrl);
setAuthUrlCopyStatus(copied ? 'copied' : 'failed');
}}
className="flex-1 rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-600"
>
{authUrlCopyStatus === 'copied' ? 'Copied' : 'Copy URL'}
</button>
</div>
</div>
</div>
)}
{showMobileAuthPanelToggle && (
<div className="absolute bottom-14 right-3 z-20 md:hidden">
<button
type="button"
onClick={() => setIsAuthPanelHidden(false)}
className="rounded bg-gray-800/95 px-3 py-2 text-xs font-medium text-gray-100 shadow-lg backdrop-blur-sm hover:bg-gray-700"
>
Show login URL
</button>
</div>
)}
</div> </div>
); );
} }

View File

@@ -26,6 +26,7 @@ const MOBILE_KEYS: Shortcut[] = [
{ type: 'arrow', id: 'arrow-down', sequence: '\x1b[B', icon: 'down' }, { type: 'arrow', id: 'arrow-down', sequence: '\x1b[B', icon: 'down' },
{ type: 'arrow', id: 'arrow-left', sequence: '\x1b[D', icon: 'left' }, { type: 'arrow', id: 'arrow-left', sequence: '\x1b[D', icon: 'left' },
{ type: 'arrow', id: 'arrow-right', sequence: '\x1b[C', icon: 'right' }, { type: 'arrow', id: 'arrow-right', sequence: '\x1b[C', icon: 'right' },
{ type: 'key', id: 'ctrl-c', label: 'Ctrl+C', sequence: '\x03' },
]; ];
const ARROW_ICONS = { const ARROW_ICONS = {

View File

@@ -137,6 +137,12 @@
height: 100%; height: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
/* The app shell is a fixed inset-0 container (see AppContent), so the
document itself never needs to scroll. Clipping it removes the phantom
full-height page scrollbar and disables the browser pull-to-refresh
gesture that reloads the page when scrolling up on mobile. */
overflow: hidden;
overscroll-behavior-y: contain;
} }
/* Root element with safe area padding for PWA */ /* Root element with safe area padding for PWA */