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',