mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 14:56:27 +00:00
feat: add an inline deploy-progress style for the stack detail (#1355)
* feat: add an inline deploy-progress style for the stack detail Deploy progress gains a presentation choice under Settings > Appearance > Display: Modal (the default centered overlay) or Inline. In Inline style a compact status band on the stack detail shows the running operation, its elapsed time, the live phase, the latest output line, and the post-update health gate result. A "View output" button opens the full log modal on demand, a dismiss control clears the band, and the band auto-clears a few seconds after a clean completion. The live progress socket is lifted to an always-mounted owner so the band streams without the modal; the default Modal style is unchanged. Operations carry their node so a band never bleeds onto a same-named stack on another node. The stack detail's redundant "CONTAINERS" section heading is removed; the band reserves that vertical space. * fix: keep inline deploy progress reachable off the stack detail Review of the inline presentation found a gap: a failed operation, an App Store install, or navigating away leaves the inline session with no visible surface, since the band only renders on the operation's own stack detail. Restore the minimized pill as the inline fallback, shown only when the band is not covering the session, so there is always a click-through to the log without ever overlapping the band. Closing the modal for a failed op now ends the session (the band has stepped aside) instead of only hiding it. Also document the unsupported mid-operation style switch, and refresh the deploy-progress, settings, appearance, and app-store docs for the renamed "Deploy progress" setting and the Modal/Inline choice.
This commit is contained in:
@@ -16,6 +16,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { StructuredLogRow } from '@/components/log-rendering/StructuredLogRow';
|
||||
import TerminalComponent from '@/components/Terminal';
|
||||
import { useDeployFeedback, VERB_LABELS, type HealthGateUiState } from '@/context/DeployFeedbackContext';
|
||||
import { useDeployFeedbackStyle } from '@/hooks/use-deploy-feedback-style';
|
||||
|
||||
const AUTO_CLOSE_SECONDS = 4;
|
||||
|
||||
@@ -40,6 +41,7 @@ function formatElapsed(seconds: number): string {
|
||||
|
||||
export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackModalProps) {
|
||||
const { panelState, healthGate, logRows, lastOutputAt, onTerminalReady, onTerminalError, onMessage, onPanelClose } = useDeployFeedback();
|
||||
const [style] = useDeployFeedbackStyle();
|
||||
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
@@ -116,8 +118,11 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
||||
// or success whose gate passed. An observing gate suspends the countdown; a
|
||||
// failed/unknown gate keeps the modal open until the user closes it.
|
||||
const gateHoldsOpen = healthGate !== null && healthGate.status !== 'passed';
|
||||
// Inline style never auto-closes the modal: the banner owns the lifecycle and
|
||||
// a manually opened log should not be yanked mid-read.
|
||||
const canAutoClose = status === 'succeeded' && isOpen && !gateHoldsOpen && style === 'modal';
|
||||
useEffect(() => {
|
||||
if (status === 'succeeded' && isOpen && !gateHoldsOpen) {
|
||||
if (canAutoClose) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
startCountdown();
|
||||
} else {
|
||||
@@ -127,7 +132,7 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
||||
return () => {
|
||||
clearCountdownInterval();
|
||||
};
|
||||
}, [status, isOpen, gateHoldsOpen, startCountdown, clearCountdownInterval]);
|
||||
}, [canAutoClose, startCountdown, clearCountdownInterval]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userScrolledUp && scrollRef.current) {
|
||||
@@ -149,16 +154,27 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
autoCloseHoveredRef.current = false;
|
||||
if (status === 'succeeded' && isOpen && !gateHoldsOpen) {
|
||||
if (canAutoClose) {
|
||||
startCountdown();
|
||||
}
|
||||
}, [status, isOpen, gateHoldsOpen, startCountdown]);
|
||||
}, [canAutoClose, startCountdown]);
|
||||
|
||||
// In Inline style, closing only hides the modal while the banner is still the
|
||||
// surface (running or cleanly done). When the op or its gate failed the banner
|
||||
// steps aside for the recovery surface, so there is nothing to return to:
|
||||
// closing ends the session (and clears the fallback pill), as Modal style
|
||||
// always does.
|
||||
const inlineFailed = status === 'failed' || healthGate?.status === 'failed';
|
||||
const closeSurface = useCallback(() => {
|
||||
if (style === 'inline' && !inlineFailed) onMinimize();
|
||||
else onPanelClose();
|
||||
}, [style, inlineFailed, onMinimize, onPanelClose]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) onPanelClose();
|
||||
if (!open) closeSurface();
|
||||
},
|
||||
[onPanelClose]
|
||||
[closeSurface]
|
||||
);
|
||||
|
||||
const isDialogOpen = isOpen && !isMinimized;
|
||||
@@ -229,7 +245,7 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={onPanelClose}
|
||||
onClick={closeSurface}
|
||||
title="Close"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
@@ -281,18 +297,30 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* always mounted so onMessage feeds structured rows even before user toggles raw view */}
|
||||
<div
|
||||
className="border-t border-glass-border shrink-0"
|
||||
style={{ height: showRaw ? '200px' : 0, overflow: 'hidden' }}
|
||||
>
|
||||
<TerminalComponent
|
||||
deploySessionId={panelState.deploySessionId}
|
||||
onReady={onTerminalReady}
|
||||
onError={onTerminalError}
|
||||
onMessage={onMessage}
|
||||
/>
|
||||
</div>
|
||||
{/* Modal style owns the live terminal here, always mounted so onMessage
|
||||
feeds structured rows even before the user toggles raw view. In Inline
|
||||
style the portal owns the single socket, so this modal (opened on
|
||||
demand) renders a text view of the captured lines instead, avoiding a
|
||||
second socket for the same session. */}
|
||||
{style === 'modal' ? (
|
||||
<div
|
||||
className="border-t border-glass-border shrink-0"
|
||||
style={{ height: showRaw ? '200px' : 0, overflow: 'hidden' }}
|
||||
>
|
||||
<TerminalComponent
|
||||
deploySessionId={panelState.deploySessionId}
|
||||
onReady={onTerminalReady}
|
||||
onError={onTerminalError}
|
||||
onMessage={onMessage}
|
||||
/>
|
||||
</div>
|
||||
) : showRaw ? (
|
||||
<div className="border-t border-glass-border shrink-0 h-[200px] overflow-auto bg-well">
|
||||
<pre className="px-3 py-2 font-mono text-[11px] leading-[1.4] text-foreground/80 whitespace-pre-wrap break-words">
|
||||
{logRows.map((r) => r.raw).join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-t border-glass-border shrink-0">
|
||||
@@ -320,7 +348,7 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={onPanelClose}
|
||||
onClick={closeSurface}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Close
|
||||
|
||||
Reference in New Issue
Block a user