+
{
+ if (!editingCompose || activeTab !== 'files') setFilesFullscreen(false);
+ }, [editingCompose, activeTab]);
+
// Below md, render the segmented full-screen mobile detail instead of the
// desktop two-pane grid. All hooks above run unconditionally before this
// branch so hook order stays stable across breakpoints.
@@ -324,8 +334,10 @@ export function EditorView(props: EditorViewProps) {
return (
-
- {/* Left column: identity + health strip + logs, stacked */}
+
+ {/* Left column: identity + health strip + logs, stacked. Hidden in
+ files fullscreen so the editor card fills the width. */}
+ {!filesFullscreen && (
{/* Command Center Card (identity + health strip) */}
@@ -396,6 +408,7 @@ export function EditorView(props: EditorViewProps) {
{/* Logs Section (fills remaining left-column height) */}
diff --git a/frontend/src/components/FleetView/__tests__/OverviewToolbar.test.tsx b/frontend/src/components/FleetView/__tests__/OverviewToolbar.test.tsx
index 8ea5cc5f..7d92e3b5 100644
--- a/frontend/src/components/FleetView/__tests__/OverviewToolbar.test.tsx
+++ b/frontend/src/components/FleetView/__tests__/OverviewToolbar.test.tsx
@@ -97,17 +97,17 @@ describe('OverviewToolbar', () => {
it('renders the Check Updates button and fires onCheckUpdates when provided', () => {
const onCheckUpdates = vi.fn();
render();
- fireEvent.click(screen.getByRole('button', { name: /Check Updates/ }));
+ fireEvent.click(screen.getByRole('button', { name: /Check for updates/ }));
expect(onCheckUpdates).toHaveBeenCalledTimes(1);
});
it('omits the Check Updates button when onCheckUpdates is not provided', () => {
render();
- expect(screen.queryByRole('button', { name: /Check Updates/ })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /Check for updates/ })).not.toBeInTheDocument();
});
it('disables the Check Updates button while a check is in flight', () => {
render();
- expect(screen.getByRole('button', { name: /Check Updates/ })).toBeDisabled();
+ expect(screen.getByRole('button', { name: /Check for updates/ })).toBeDisabled();
});
});
diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx
index 222144c9..6ec431b8 100644
--- a/frontend/src/components/NodeManager.tsx
+++ b/frontend/src/components/NodeManager.tsx
@@ -19,7 +19,7 @@ import { useAuth } from '@/context/AuthContext';
import { useNodeActions, type NodeTestInfo } from './nodes/useNodeActions';
import { useFleetSyncStatus } from '@/hooks/useFleetSyncStatus';
import { resetFleetSyncAnchor, STICKY_CONTROL_IDENTITY_MISMATCH } from '@/lib/fleetSyncApi';
-import type { SecurityTab } from '@/lib/events';
+import type { SecurityTab, FleetTab } from '@/lib/events';
interface NodeSchedulingSummary {
active_tasks: number;
@@ -30,10 +30,12 @@ interface NodeSchedulingSummary {
export const SENCHO_NAVIGATE_EVENT = 'sencho-navigate';
export interface SenchoNavigateDetail {
- view: 'scheduled-ops' | 'auto-updates' | 'security';
+ view: 'scheduled-ops' | 'auto-updates' | 'security' | 'fleet';
nodeId?: number;
/** Target tab when navigating to the Security view. */
tab?: SecurityTab;
+ /** Target tab when navigating to the Fleet view (e.g. 'snapshots'). */
+ fleetTab?: FleetTab;
}
export function NodeManager() {
diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx
index b6b26f32..da62f2e7 100644
--- a/frontend/src/components/StackAnatomyPanel.tsx
+++ b/frontend/src/components/StackAnatomyPanel.tsx
@@ -6,6 +6,7 @@ import { ScrollableTabRow } from './ui/ScrollableTabRow';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/utils';
import { type AnatomyMarkdownInput, type PortRow, type VolumeRow } from '@/lib/anatomyMarkdown';
+import { usePreflightDismiss } from '@/hooks/usePreflightDismiss';
import { parseAnatomy, parseEnvKeys, formatGitSource, primaryPublishedHostPort, type GitSourceInfo } from '@/lib/anatomy';
import { buildServiceUrl } from '@/lib/serviceUrl';
import { StackActivityTimeline } from './stack/StackActivityTimeline';
@@ -108,6 +109,12 @@ export default function StackAnatomyPanel({
// active tab content lazily, so the badge cannot come from PreflightPanel; the
// parent reads the stored run once per stack/node change.
const [preflightSeverity, setPreflightSeverity] = useState(null);
+ // Findings power the dismiss fingerprint so the dot clears in lockstep with the
+ // banner and re-appears when the findings change.
+ const [preflightFindings, setPreflightFindings] = useState | undefined>(undefined);
+ // The Doctor tab dot clears when the high-risk banner is dismissed, and returns
+ // when the findings change (shared fingerprint with PreflightPanel).
+ const { dismissed: doctorDismissed } = usePreflightDismiss(stackName, activeNode?.id, preflightFindings);
const [scanStatus, setScanStatus] = useState<{
status: 'ok' | 'partial' | 'failed' | 'skipped' | null;
attemptedAt?: number;
@@ -126,9 +133,12 @@ export default function StackAnatomyPanel({
const res = await apiFetch(`/stacks/${stackName}/preflight`);
if (cancelled || !res.ok) return;
const data = await res.json();
- if (!cancelled) setPreflightSeverity(typeof data?.highestSeverity === 'string' ? data.highestSeverity : null);
+ if (!cancelled) {
+ setPreflightSeverity(typeof data?.highestSeverity === 'string' ? data.highestSeverity : null);
+ setPreflightFindings(Array.isArray(data?.findings) ? data.findings : undefined);
+ }
} catch {
- if (!cancelled) setPreflightSeverity(null);
+ if (!cancelled) { setPreflightSeverity(null); setPreflightFindings(undefined); }
}
})();
return () => { cancelled = true; };
@@ -363,7 +373,7 @@ export default function StackAnatomyPanel({
Doctor
- {(preflightSeverity === 'blocker' || preflightSeverity === 'high') && (
+ {(preflightSeverity === 'blocker' || preflightSeverity === 'high') && !doctorDismissed && (
{
+ try { return localStorage.getItem(WORD_WRAP_KEY) !== 'false'; } catch { return true; }
+ });
+ useEffect(() => {
+ try { localStorage.setItem(WORD_WRAP_KEY, String(wordWrap)); } catch { /* ignore */ }
+ }, [wordWrap]);
+
// Stash the latest callback in a ref so the unmount-cleanup effect can be
// truly unmount-scoped without re-running every time a parent passes a fresh
// function identity.
@@ -164,8 +175,9 @@ export function FileViewer({
fontSize: 13,
padding: { top: 8 },
scrollBeyondLastLine: false,
+ wordWrap: wordWrap ? ('on' as const) : ('off' as const),
}),
- [readOnly],
+ [readOnly, wordWrap],
);
useEffect(() => {
@@ -350,6 +362,17 @@ export function FileViewer({
{filename}
+ setWordWrap((v) => !v)}
+ aria-pressed={wordWrap}
+ title={wordWrap ? 'Word wrap on' : 'Word wrap off'}
+ aria-label={wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
+ >
+
+
{readOnly && (
Read-only
diff --git a/frontend/src/components/fleet/FleetEmptyState.tsx b/frontend/src/components/fleet/FleetEmptyState.tsx
index 78b8be02..19cdfa2b 100644
--- a/frontend/src/components/fleet/FleetEmptyState.tsx
+++ b/frontend/src/components/fleet/FleetEmptyState.tsx
@@ -8,15 +8,16 @@ interface FleetTabHeadingProps {
}
/**
- * Standardized Fleet tab header: italic-serif title and muted subtitle on the
+ * Standardized Fleet tab header: heading-styled title and muted subtitle on the
* left, an optional primary action on the right. Rendered in both empty and
- * populated states so the tab chrome stays consistent.
+ * populated states so the tab chrome stays consistent. The title follows the
+ * theme heading style (signature italic or calm) via .font-heading.
*/
export function FleetTabHeading({ title, subtitle, action }: FleetTabHeadingProps) {
return (