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,91 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DeployFeedbackModal } from '../DeployFeedbackModal';
import type { DeployPanelState } from '@/context/DeployFeedbackContext';
const onPanelClose = vi.fn();
const onMinimize = vi.fn();
let mockStyle: 'modal' | 'inline';
let mockPanelState: DeployPanelState;
vi.mock('@/context/DeployFeedbackContext', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/DeployFeedbackContext')>();
return {
...actual,
useDeployFeedback: () => ({
panelState: mockPanelState,
healthGate: null,
logRows: [],
lastOutputAt: 0,
onTerminalReady: vi.fn(),
onTerminalError: vi.fn(),
onMessage: vi.fn(),
onPanelClose,
runWithLog: vi.fn(),
minimized: false,
setMinimized: vi.fn(),
}),
};
});
vi.mock('@/hooks/use-deploy-feedback-style', () => ({
useDeployFeedbackStyle: () => [mockStyle, vi.fn()],
}));
vi.mock('@/components/Terminal', () => ({ default: () => <div data-testid="modal-terminal" /> }));
function panel(over: Partial<DeployPanelState> = {}): DeployPanelState {
return {
isOpen: true, stackName: 'web', nodeId: null, action: 'update', status: 'streaming',
progressUnavailable: false, deploySessionId: 'abc', sessionId: 1, ...over,
};
}
beforeEach(() => {
onPanelClose.mockClear();
onMinimize.mockClear();
mockStyle = 'inline';
mockPanelState = panel();
});
function clickClose() {
// Both the header icon and the footer button are named "Close" and route
// through the same handler; either click exercises it.
fireEvent.click(screen.getAllByRole('button', { name: 'Close' })[0]);
}
describe('DeployFeedbackModal Inline vs Modal style', () => {
it('inline style: closing only hides the modal (keeps the session for the banner)', () => {
mockStyle = 'inline';
render(<DeployFeedbackModal isMinimized={false} onMinimize={onMinimize} />);
clickClose();
expect(onMinimize).toHaveBeenCalledTimes(1);
expect(onPanelClose).not.toHaveBeenCalled();
});
it('inline style: closing a failed op ends the session (the banner has stepped aside)', () => {
mockStyle = 'inline';
mockPanelState = panel({ status: 'failed' });
render(<DeployFeedbackModal isMinimized={false} onMinimize={onMinimize} />);
clickClose();
expect(onPanelClose).toHaveBeenCalledTimes(1);
expect(onMinimize).not.toHaveBeenCalled();
});
it('modal style: closing ends the session', () => {
mockStyle = 'modal';
render(<DeployFeedbackModal isMinimized={false} onMinimize={onMinimize} />);
clickClose();
expect(onPanelClose).toHaveBeenCalledTimes(1);
expect(onMinimize).not.toHaveBeenCalled();
});
it('modal style owns the live terminal; inline style renders no terminal (single socket)', () => {
mockStyle = 'modal';
const view = render(<DeployFeedbackModal isMinimized={false} onMinimize={onMinimize} />);
expect(screen.getByTestId('modal-terminal')).toBeInTheDocument();
view.unmount();
mockStyle = 'inline';
render(<DeployFeedbackModal isMinimized={false} onMinimize={onMinimize} />);
expect(screen.queryByTestId('modal-terminal')).toBeNull();
});
});
@@ -33,7 +33,7 @@ let runOuter: Promise<unknown> | null = null;
function Driver() {
const { runWithLog } = useDeployFeedback();
React.useEffect(() => {
runOuter = runWithLog({ stackName: 'web', action: 'update' }, async (started) => {
runOuter = runWithLog({ stackName: 'web', action: 'update', nodeId: null }, async (started) => {
await started;
return new Promise<{ ok: boolean; errorMessage?: string; healthGateId?: string | null }>((res) => { resolveRun = res; });
});
@@ -0,0 +1,100 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DeployFeedbackPortal } from '../DeployFeedbackPortal';
import type { DeployPanelState } from '@/context/DeployFeedbackContext';
let mockPanelState: DeployPanelState;
let mockStyle: 'modal' | 'inline';
let mockBannerActive: boolean;
vi.mock('@/context/DeployFeedbackContext', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/DeployFeedbackContext')>();
return {
...actual,
useDeployFeedback: () => ({
panelState: mockPanelState,
minimized: true,
setMinimized: vi.fn(),
bannerActive: mockBannerActive,
setBannerActive: vi.fn(),
onTerminalReady: vi.fn(),
onTerminalError: vi.fn(),
onMessage: vi.fn(),
healthGate: null,
logRows: [],
lastOutputAt: 0,
runWithLog: vi.fn(),
onPanelClose: vi.fn(),
}),
};
});
vi.mock('@/hooks/use-deploy-feedback-style', () => ({
useDeployFeedbackStyle: () => [mockStyle, vi.fn()],
}));
vi.mock('../DeployFeedbackModal', () => ({ DeployFeedbackModal: () => <div data-testid="modal-marker" /> }));
vi.mock('../DeployFeedbackPill', () => ({
DeployFeedbackPill: ({ isVisible }: { isVisible: boolean }) => (isVisible ? <div data-testid="pill-marker" /> : null),
}));
vi.mock('../Terminal', () => ({ default: () => <div data-testid="portal-terminal" /> }));
function panel(over: Partial<DeployPanelState> = {}): DeployPanelState {
return {
isOpen: false, stackName: '', nodeId: null, action: 'deploy', status: 'preparing',
progressUnavailable: false, deploySessionId: '', sessionId: 0, ...over,
};
}
beforeEach(() => {
mockStyle = 'inline';
mockBannerActive = false;
mockPanelState = panel();
});
// Exactly one terminal owns the per-session socket: the portal mounts it in
// Inline style (the modal stays closed there), and the modal owns it in Modal
// style. This pins the portal's half of that invariant.
describe('DeployFeedbackPortal', () => {
it('mounts the progress terminal in inline style while a session is open', () => {
mockStyle = 'inline';
mockPanelState = panel({ isOpen: true });
render(<DeployFeedbackPortal />);
expect(screen.getByTestId('portal-terminal')).toBeInTheDocument();
});
it('does not mount the portal terminal in modal style (the modal owns it)', () => {
mockStyle = 'modal';
mockPanelState = panel({ isOpen: true });
render(<DeployFeedbackPortal />);
expect(screen.queryByTestId('portal-terminal')).toBeNull();
});
it('does not mount the portal terminal when no session is open', () => {
mockStyle = 'inline';
mockPanelState = panel({ isOpen: false });
render(<DeployFeedbackPortal />);
expect(screen.queryByTestId('portal-terminal')).toBeNull();
});
it('shows the minimize pill in modal style when open and minimized', () => {
mockStyle = 'modal';
mockPanelState = panel({ isOpen: true });
render(<DeployFeedbackPortal />);
expect(screen.getByTestId('pill-marker')).toBeInTheDocument();
});
it('hides the pill in inline style while the banner is covering the session', () => {
mockStyle = 'inline';
mockBannerActive = true;
mockPanelState = panel({ isOpen: true });
render(<DeployFeedbackPortal />);
expect(screen.queryByTestId('pill-marker')).toBeNull();
});
it('shows the fallback pill in inline style when the banner is not active', () => {
mockStyle = 'inline';
mockBannerActive = false; // App Store, off-detail, or a failed op
mockPanelState = panel({ isOpen: true });
render(<DeployFeedbackPortal />);
expect(screen.getByTestId('pill-marker')).toBeInTheDocument();
});
});