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:
committed by
GitHub
parent
d948aa5557
commit
95c71789c4
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user