Project actions: run commands from header on web + mobile, with SSH-forward URL opening (#542)

* feat: add project actions in header with reliable terminal run flow

- Add per-project Actions settings with icons, platform filters, and default action
- Run/stop actions from header using terminal tabs, including Ctrl+C then force-kill fallback
- Improve terminal UX with stable selection, resize handling, and smarter URL auto-open for localhost ports

* feat: add project actions UI with refined header dropdown behavior

* feat: add parent-session back button in chat

* fix: make web and desktop dev modes reliable and conflict-free

- Separate desktop and web dev ports to avoid collisions
- Add robust process-tree shutdown so Ctrl+C cleans sidecars
- Add true web HMR mode and keep service worker out of dev

* fix: linux safe scripts for dev

* feat: run project actions on web and add desktop SSH forward URL opening

* feat: add mobile project actions button with terminal tabs and tighter tab UI

* revert: remove experimental mobile terminal selection UI

* feat: show Add action button in header when empty

* fix: make retry countdown human-readable in status row

* fix: prevent nav rail actions from firing through overlays
This commit is contained in:
Bohdan Triapitsyn
2026-02-27 20:45:03 +02:00
committed by GitHub
parent d948aa5557
commit 95c71789c4
26 changed files with 2572 additions and 148 deletions
@@ -55,6 +55,16 @@ type TerminalController = {
fit: () => void;
};
type TerminalWithViewport = {
scrollToBottom?: () => void;
getViewportY?: () => number;
hasSelection?: () => boolean;
};
type FitAddonWithObserveResize = FitAddon & {
observeResize?: () => void;
};
interface TerminalViewportProps {
sessionKey: string;
chunks: TerminalChunk[];
@@ -97,6 +107,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
const writeScheduledRef = React.useRef<number | null>(null);
const isWritingRef = React.useRef(false);
const lastProcessedChunkIdRef = React.useRef<number | null>(null);
const followOutputRef = React.useRef(true);
const touchScrollCleanupRef = React.useRef<(() => void) | null>(null);
const viewportDiscoveryTimeoutRef = React.useRef<number | null>(null);
const viewportDiscoveryAttemptsRef = React.useRef(0);
@@ -313,7 +324,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
React.useEffect(() => {
const container = containerRef.current;
if (!useHiddenInputOverlay || !container) {
if (!useHiddenInputOverlay || !container || enableTouchScroll) {
return;
}
@@ -338,7 +349,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
return () => {
container.removeEventListener('focusin', handleContainerFocusIn, true);
};
}, [useHiddenInputOverlay, focusHiddenInput]);
}, [enableTouchScroll, useHiddenInputOverlay, focusHiddenInput]);
const getTerminalSelectionText = React.useCallback((): string => {
const terminal = terminalRef.current as unknown as {
@@ -834,10 +845,9 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
scrollByPixels(deltaPixels);
};
const handleTouchEnd = (event: TouchEvent) => {
const handleTouchEnd = (event: TouchEvent) => {
const wasTap = !state.didMove;
state.lastY = null;
state.lastTime = null;
@@ -918,6 +928,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
let localResizeObserver: ResizeObserver | null = null;
let localTextareaObserver: MutationObserver | null = null;
let localDisposables: Array<{ dispose: () => void }> = [];
let restorePatchedScrollToBottom: (() => void) | null = null;
const container = containerRef.current;
if (!container) {
@@ -956,9 +967,23 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
return;
}
const options = getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, useHiddenInputOverlay);
const options = getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false);
const terminal = new GhosttyTerminal(options);
followOutputRef.current = true;
const terminalWithViewport = terminal as unknown as TerminalWithViewport;
if (typeof terminalWithViewport.scrollToBottom === 'function') {
const originalScrollToBottom = terminalWithViewport.scrollToBottom.bind(terminalWithViewport);
terminalWithViewport.scrollToBottom = () => {
if (followOutputRef.current) {
originalScrollToBottom();
}
};
restorePatchedScrollToBottom = () => {
terminalWithViewport.scrollToBottom = originalScrollToBottom;
};
}
const fitAddon = new FitAddon();
@@ -999,11 +1024,31 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
}
fitTerminal();
const fitAddonWithResize = fitAddon as FitAddonWithObserveResize;
if (typeof fitAddonWithResize.observeResize === 'function') {
fitAddonWithResize.observeResize();
}
setupTouchScroll();
localDisposables = [
terminal.onData((data: string) => {
inputHandlerRef.current(data);
}),
terminal.onScroll((viewportY: number) => {
if (typeof viewportY === 'number' && Number.isFinite(viewportY)) {
const hasSelection = typeof terminal.hasSelection === 'function' && terminal.hasSelection();
followOutputRef.current = !hasSelection && viewportY <= 0.5;
}
}),
terminal.onSelectionChange(() => {
const hasSelection = typeof terminal.hasSelection === 'function' && terminal.hasSelection();
if (hasSelection) {
followOutputRef.current = false;
return;
}
const viewportY = typeof terminal.getViewportY === 'function' ? terminal.getViewportY() : 0;
followOutputRef.current = viewportY <= 0.5;
}),
];
localResizeObserver = new ResizeObserver(() => {
@@ -1035,6 +1080,8 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
window.removeEventListener('blur', handleWindowBlur);
localDisposables.forEach((disposable) => disposable.dispose());
restorePatchedScrollToBottom?.();
restorePatchedScrollToBottom = null;
if (localTerminalTextarea) {
localTerminalTextarea.removeEventListener('focus', handleTerminalTextareaFocus);
localTerminalTextarea.removeEventListener('blur', handleTerminalTextareaBlur);
@@ -1421,25 +1468,34 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
className={cn('relative h-full w-full terminal-viewport-container', className)}
style={{ backgroundColor: theme.background }}
onTouchStart={(event) => {
if (useHiddenInputOverlay) {
if (!useHiddenInputOverlay || enableTouchScroll) {
return;
}
if (!hasCopyableSelectionInViewport()) {
const touch = event.touches?.[0];
focusHiddenInput(touch?.clientX, touch?.clientY);
}
}}
onClick={(event) => {
if (useHiddenInputOverlay) {
if (enableTouchScroll) {
return;
}
if (hasCopyableSelectionInViewport()) {
return;
}
focusHiddenInput(event.clientX, event.clientY);
} else {
terminalRef.current?.focus();
}
}}
onMouseUp={() => {
if (!enableTouchScroll) {
if (!enableTouchScroll && hasCopyableSelectionInViewport()) {
void copySelectionToClipboard();
}
}}
onTouchEnd={() => {
if (!enableTouchScroll) {
if (!enableTouchScroll && hasCopyableSelectionInViewport()) {
void copySelectionToClipboard();
}
}}