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
@@ -1,5 +1,5 @@
import React from 'react';
import { RiArrowDownLine } from '@remixicon/react';
import { RiArrowDownLine, RiArrowLeftLine } from '@remixicon/react';
import { useShallow } from 'zustand/react/shallow';
import type { Message, Part } from '@opencode-ai/sdk/v2';
@@ -14,6 +14,7 @@ import { useChatScrollManager } from '@/hooks/useChatScrollManager';
import { useDeviceInfo } from '@/lib/device';
import { getMemoryLimits } from '@/stores/types/sessionTypes';
import { Button } from '@/components/ui/button';
import { ButtonSmall } from '@/components/ui/button-small';
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
import { TimelineDialog } from './TimelineDialog';
import type { PermissionRequest } from '@/types/permission';
@@ -76,6 +77,7 @@ export const ChatContainer: React.FC = () => {
loadMoreMessages,
updateViewportAnchor,
openNewSessionDraft,
setCurrentSession,
trimToViewportWindow,
newSessionDraft,
} = useSessionStore(
@@ -86,6 +88,7 @@ export const ChatContainer: React.FC = () => {
loadMoreMessages: state.loadMoreMessages,
updateViewportAnchor: state.updateViewportAnchor,
openNewSessionDraft: state.openNewSessionDraft,
setCurrentSession: state.setCurrentSession,
trimToViewportWindow: state.trimToViewportWindow,
newSessionDraft: state.newSessionDraft,
}))
@@ -112,6 +115,8 @@ export const ChatContainer: React.FC = () => {
)
);
const sessions = useSessionStore((state) => state.sessions);
const blockingRequestState = useSessionStore(
useShallow((state) => ({
sessions: state.sessions,
@@ -168,6 +173,42 @@ export const ChatContainer: React.FC = () => {
const isDesktopExpandedInput = isExpandedInput && !isMobile;
const messageListRef = React.useRef<MessageListHandle | null>(null);
const parentSession = React.useMemo(() => {
if (!currentSessionId) {
return null;
}
const current = sessions.find((session) => session.id === currentSessionId);
const parentID = current?.parentID;
if (!parentID) {
return null;
}
return sessions.find((session) => session.id === parentID) ?? null;
}, [currentSessionId, sessions]);
const handleReturnToParentSession = React.useCallback(() => {
if (!parentSession) {
return;
}
void setCurrentSession(parentSession.id);
}, [parentSession, setCurrentSession]);
const returnToParentButton = parentSession ? (
<ButtonSmall
type="button"
variant="outline"
size="xs"
onClick={handleReturnToParentSession}
className="absolute left-3 top-3 z-20 !font-normal bg-[var(--surface-background)]/95"
aria-label="Return to parent session"
title={parentSession.title?.trim() ? `Return to: ${parentSession.title}` : 'Return to parent session'}
>
<RiArrowLeftLine className="h-4 w-4" />
Parent
</ButtonSmall>
) : null;
React.useEffect(() => {
if (!currentSessionId && !draftOpen) {
openNewSessionDraft();
@@ -487,9 +528,10 @@ export const ChatContainer: React.FC = () => {
if (!hasMessagesEntry) {
return (
<div
className="flex flex-col h-full bg-background gap-0"
className="relative flex flex-col h-full bg-background gap-0"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
{returnToParentButton}
<div className="flex-1 overflow-y-auto p-4 bg-background">
<div className="chat-message-column space-y-4">
{[1, 2, 3].map((i) => (
@@ -515,6 +557,7 @@ export const ChatContainer: React.FC = () => {
className="relative flex flex-col h-full bg-background transform-gpu"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
{returnToParentButton}
{!isDesktopExpandedInput ? (
<div className="flex-1 flex items-center justify-center">
<ChatEmptyState />
@@ -539,6 +582,7 @@ export const ChatContainer: React.FC = () => {
className="relative flex flex-col h-full bg-background"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
{returnToParentButton}
<div
className={cn(
'relative min-h-0',
@@ -11,6 +11,46 @@ interface WorkingPlaceholderProps {
const STATUS_DISPLAY_TIME_MS = 1200;
const EPOCH_SECONDS_THRESHOLD = 1_000_000_000;
const EPOCH_MILLISECONDS_THRESHOLD = 1_000_000_000_000;
const toRetryTargetTimestamp = (next: number): number => {
if (next >= EPOCH_MILLISECONDS_THRESHOLD) {
return next;
}
if (next >= EPOCH_SECONDS_THRESHOLD) {
return next * 1000;
}
return Date.now() + next;
};
const formatRetryCountdown = (seconds: number): string => {
if (seconds < 60) {
return `${seconds}s`;
}
if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
const remainderSeconds = seconds % 60;
return remainderSeconds > 0 ? `${minutes}m ${remainderSeconds}s` : `${minutes}m`;
}
if (seconds < 86400) {
const hours = Math.floor(seconds / 3600);
const remainderMinutes = Math.floor((seconds % 3600) / 60);
return remainderMinutes > 0 ? `${hours}h ${remainderMinutes}m` : `${hours}h`;
}
const days = Math.floor(seconds / 86400);
const remainderHours = Math.floor((seconds % 86400) / 3600);
if (remainderHours > 0) {
return `${days}d ${remainderHours}h`;
}
return `${days}d`;
};
export function WorkingPlaceholder({
isWorking,
statusText,
@@ -26,26 +66,19 @@ export function WorkingPlaceholder({
const processQueueTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
// Countdown state for retry mode
const retryNextRef = React.useRef<number | null>(null);
const retryStartRef = React.useRef<number | null>(null);
const [retryCountdown, setRetryCountdown] = React.useState<number | null>(null);
React.useEffect(() => {
const next = retryInfo?.next;
if (!next || next <= 0) {
retryNextRef.current = null;
retryStartRef.current = null;
const rawNext = retryInfo?.next;
if (!rawNext || rawNext <= 0) {
setRetryCountdown(null);
return;
}
// Start a fresh countdown when next value or attempt changes
retryNextRef.current = next;
retryStartRef.current = Date.now();
const retryTargetAt = toRetryTargetTimestamp(rawNext);
const update = () => {
const elapsed = Date.now() - (retryStartRef.current ?? Date.now());
const remaining = Math.max(0, next - elapsed);
const remaining = Math.max(0, retryTargetAt - Date.now());
setRetryCountdown(Math.ceil(remaining / 1000));
};
@@ -151,7 +184,9 @@ export function WorkingPlaceholder({
// Retry state: show countdown and attempt info
if (retryInfo) {
const attemptLabel = retryInfo.attempt && retryInfo.attempt > 1 ? ` (attempt ${retryInfo.attempt})` : '';
const countdownLabel = retryCountdown !== null && retryCountdown > 0 ? ` in ${retryCountdown}s` : '';
const countdownLabel = retryCountdown !== null && retryCountdown > 0
? ` in ${formatRetryCountdown(retryCountdown)}`
: '';
const retryText = `Retrying${countdownLabel}${attemptLabel}...`;
return (