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:
Anso
2026-06-11 10:33:57 -04:00
committed by GitHub
parent 38aabe7064
commit e20f1fe415
30 changed files with 1258 additions and 73 deletions
@@ -0,0 +1,43 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useDeployFeedbackStyle, DEPLOY_FEEDBACK_STYLE_KEY } from '../use-deploy-feedback-style';
describe('useDeployFeedbackStyle (modal default)', () => {
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
it('defaults to modal when no value is stored', () => {
const { result } = renderHook(() => useDeployFeedbackStyle());
expect(result.current[0]).toBe('modal');
});
it('reads inline only when explicitly set to inline', () => {
localStorage.setItem(DEPLOY_FEEDBACK_STYLE_KEY, 'inline');
const { result } = renderHook(() => useDeployFeedbackStyle());
expect(result.current[0]).toBe('inline');
});
it('treats any unknown value as modal', () => {
localStorage.setItem(DEPLOY_FEEDBACK_STYLE_KEY, 'something-else');
const { result } = renderHook(() => useDeployFeedbackStyle());
expect(result.current[0]).toBe('modal');
});
it('setStyle persists and switches', () => {
const { result } = renderHook(() => useDeployFeedbackStyle());
act(() => result.current[1]('inline'));
expect(result.current[0]).toBe('inline');
expect(localStorage.getItem(DEPLOY_FEEDBACK_STYLE_KEY)).toBe('inline');
act(() => result.current[1]('modal'));
expect(result.current[0]).toBe('modal');
});
it('reacts to a storage event from another tab', () => {
const { result } = renderHook(() => useDeployFeedbackStyle());
expect(result.current[0]).toBe('modal');
act(() => {
window.dispatchEvent(new StorageEvent('storage', { key: DEPLOY_FEEDBACK_STYLE_KEY, newValue: 'inline' }));
});
expect(result.current[0]).toBe('inline');
});
});
@@ -0,0 +1,58 @@
import { useCallback, useEffect, useState } from 'react';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
export const DEPLOY_FEEDBACK_STYLE_KEY = 'sencho.deploy-feedback.style';
export type DeployFeedbackStyle = 'modal' | 'inline';
// How the live deploy/update progress surfaces while it streams. 'modal' (the
// default) is the centered overlay; 'inline' is the quiet in-page banner on the
// stack detail with the full log behind its "View output" button. Only an
// explicit 'inline' selects the banner; anything else stays on the modal, so a
// missing or unknown value is safe.
// Read the persisted style synchronously. Exported so non-React callers (the
// deploy-feedback context at deploy time) can read the current value directly
// rather than depend on a reactive snapshot that an event might not have
// refreshed yet.
export function readDeployFeedbackStyle(): DeployFeedbackStyle {
if (typeof window === 'undefined') return 'modal';
try {
return window.localStorage.getItem(DEPLOY_FEEDBACK_STYLE_KEY) === 'inline' ? 'inline' : 'modal';
} catch {
// localStorage unavailable (private mode, quota): fall back to the modal default.
return 'modal';
}
}
export function useDeployFeedbackStyle(): [DeployFeedbackStyle, (next: DeployFeedbackStyle) => void] {
const [style, setStyleState] = useState<DeployFeedbackStyle>(readDeployFeedbackStyle);
useEffect(() => {
function onSettingsChanged() {
setStyleState(readDeployFeedbackStyle());
}
window.addEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
}, []);
useEffect(() => {
function onStorage(event: StorageEvent) {
if (event.key !== DEPLOY_FEEDBACK_STYLE_KEY) return;
setStyleState(event.newValue === 'inline' ? 'inline' : 'modal');
}
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
const setStyle = useCallback((next: DeployFeedbackStyle) => {
try {
window.localStorage.setItem(DEPLOY_FEEDBACK_STYLE_KEY, next);
} catch {
// ignore; localStorage may be unavailable (private mode, quota)
}
setStyleState(next);
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
}, []);
return [style, setStyle];
}