fix(dashboard): resizable mission sidebar and cleaner card header
- Make the mission split sidebar drag-resizable (220–560px, persisted) so long mission titles aren't trapped behind a fixed-width column. - Move card tags (autopilot/health/status) to a row below the title and drop the overflowing "Active: …" line. - Collapse mission creation to a single AI-driven entry point: rename Sparkles to "Create New Mission" and remove the manual "+" button. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
9
.changeset/mission-view-sidebar-ux.md
Normal file
9
.changeset/mission-view-sidebar-ux.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Mission view sidebar and list-card UX fixes.
|
||||||
|
|
||||||
|
- **Resizable mission sidebar**: the desktop split sidebar is now drag-resizable via a vertical handle (also keyboard-accessible with arrow keys). Width persists to `localStorage` (`fusion:mission-sidebar-width`), bounded 220–560px, default 300px. Previously fixed at ~284px with `flex-shrink: 0`.
|
||||||
|
- **Mission card title no longer truncates aggressively**: tags (autopilot zap, health badge, status pill) moved to a second row below the title so the title can use the full card width. Removed the redundant overflow-prone `Active: …` line that was sometimes spilling outside the card.
|
||||||
|
- **Single AI-driven create flow**: removed the manual `+ New Mission` button from the sidebar header and bottom footer. The Sparkles button (now labeled "Create New Mission") is the only entry point — the dead `handleCreateMission` callback and unused `activeSliceLabel` were removed too.
|
||||||
@@ -164,7 +164,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mission-manager__sidebar {
|
.mission-manager__sidebar {
|
||||||
width: calc((var(--space-xl) * 11) + var(--space-md) + var(--space-xs));
|
width: 300px;
|
||||||
|
min-width: 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -173,6 +174,36 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mission-manager__sidebar-resize-handle {
|
||||||
|
position: relative;
|
||||||
|
width: var(--space-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: col-resize;
|
||||||
|
background: transparent;
|
||||||
|
touch-action: none;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-manager__sidebar-resize-handle::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: var(--space-xs);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-manager__sidebar-resize-handle:hover::before,
|
||||||
|
.mission-manager__sidebar-resize-handle:active::before {
|
||||||
|
background: color-mix(in srgb, var(--todo) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-manager__sidebar-resize-handle:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring-strong);
|
||||||
|
}
|
||||||
|
|
||||||
/* Desktop split-only sidebar header layout for mission quick actions. */
|
/* Desktop split-only sidebar header layout for mission quick actions. */
|
||||||
.mission-manager__sidebar-header {
|
.mission-manager__sidebar-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -662,6 +693,15 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-list__item-tags {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
margin-top: var(--space-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mission-list__item-icon {
|
.mission-list__item-icon {
|
||||||
@@ -670,6 +710,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mission-list__item-title {
|
.mission-list__item-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -782,12 +824,6 @@
|
|||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mission-list__item-active-slice {
|
|
||||||
margin: var(--space-xs) 0 0;
|
|
||||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
Mission Detail View
|
Mission Detail View
|
||||||
================================================================ */
|
================================================================ */
|
||||||
|
|||||||
@@ -97,6 +97,11 @@ import {
|
|||||||
} from "../api";
|
} from "../api";
|
||||||
import type { AutopilotState } from "./mission-types";
|
import type { AutopilotState } from "./mission-types";
|
||||||
|
|
||||||
|
const MISSION_SIDEBAR_DEFAULT_WIDTH = 300;
|
||||||
|
const MISSION_SIDEBAR_MIN_WIDTH = 220;
|
||||||
|
const MISSION_SIDEBAR_MAX_WIDTH = 560;
|
||||||
|
const MISSION_SIDEBAR_STORAGE_KEY = "fusion:mission-sidebar-width";
|
||||||
|
|
||||||
interface MissionManagerProps {
|
interface MissionManagerProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
isInline?: boolean;
|
isInline?: boolean;
|
||||||
@@ -439,6 +444,72 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const isMobile = useViewportMode() === "mobile";
|
const isMobile = useViewportMode() === "mobile";
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
|
||||||
|
if (typeof window === "undefined") return MISSION_SIDEBAR_DEFAULT_WIDTH;
|
||||||
|
const stored = window.localStorage.getItem(MISSION_SIDEBAR_STORAGE_KEY);
|
||||||
|
const parsed = stored ? Number(stored) : NaN;
|
||||||
|
if (!Number.isFinite(parsed)) return MISSION_SIDEBAR_DEFAULT_WIDTH;
|
||||||
|
return Math.max(MISSION_SIDEBAR_MIN_WIDTH, Math.min(MISSION_SIDEBAR_MAX_WIDTH, parsed));
|
||||||
|
});
|
||||||
|
|
||||||
|
const persistSidebarWidth = useCallback((width: number) => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(MISSION_SIDEBAR_STORAGE_KEY, String(width));
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSidebarResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (isMobile) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const handle = event.currentTarget;
|
||||||
|
if (typeof handle.setPointerCapture === "function") {
|
||||||
|
handle.setPointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
const startX = event.clientX;
|
||||||
|
const startWidth = sidebarWidth;
|
||||||
|
let latestWidth = startWidth;
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
|
||||||
|
const onPointerMove = (moveEvent: PointerEvent) => {
|
||||||
|
const deltaX = moveEvent.clientX - startX;
|
||||||
|
const nextWidth = Math.max(
|
||||||
|
MISSION_SIDEBAR_MIN_WIDTH,
|
||||||
|
Math.min(MISSION_SIDEBAR_MAX_WIDTH, startWidth + deltaX),
|
||||||
|
);
|
||||||
|
latestWidth = nextWidth;
|
||||||
|
setSidebarWidth(nextWidth);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerUp = (upEvent: PointerEvent) => {
|
||||||
|
if (typeof handle.releasePointerCapture === "function") {
|
||||||
|
handle.releasePointerCapture(upEvent.pointerId);
|
||||||
|
}
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
document.removeEventListener("pointermove", onPointerMove);
|
||||||
|
document.removeEventListener("pointerup", onPointerUp);
|
||||||
|
persistSidebarWidth(latestWidth);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("pointermove", onPointerMove);
|
||||||
|
document.addEventListener("pointerup", onPointerUp);
|
||||||
|
}, [isMobile, persistSidebarWidth, sidebarWidth]);
|
||||||
|
|
||||||
|
const handleSidebarResizeKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (isMobile) return;
|
||||||
|
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
||||||
|
event.preventDefault();
|
||||||
|
const step = event.shiftKey ? 50 : 10;
|
||||||
|
const delta = event.key === "ArrowLeft" ? -step : step;
|
||||||
|
const nextWidth = Math.max(
|
||||||
|
MISSION_SIDEBAR_MIN_WIDTH,
|
||||||
|
Math.min(MISSION_SIDEBAR_MAX_WIDTH, sidebarWidth + delta),
|
||||||
|
);
|
||||||
|
setSidebarWidth(nextWidth);
|
||||||
|
persistSidebarWidth(nextWidth);
|
||||||
|
}, [isMobile, persistSidebarWidth, sidebarWidth]);
|
||||||
|
|
||||||
// Form states
|
// Form states
|
||||||
const [isCreatingMission, setIsCreatingMission] = useState(false);
|
const [isCreatingMission, setIsCreatingMission] = useState(false);
|
||||||
@@ -1116,12 +1187,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Mission handlers
|
// Mission handlers
|
||||||
const handleCreateMission = useCallback(() => {
|
|
||||||
setIsCreatingMission(true);
|
|
||||||
setEditingMissionId(null);
|
|
||||||
setMissionForm(EMPTY_MISSION_FORM);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleEditMission = useCallback((mission: Mission) => {
|
const handleEditMission = useCallback((mission: Mission) => {
|
||||||
setEditingMissionId(mission.id);
|
setEditingMissionId(mission.id);
|
||||||
setIsCreatingMission(false);
|
setIsCreatingMission(false);
|
||||||
@@ -3394,10 +3459,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
const progressPercent = health?.estimatedCompletionPercent ?? summary?.progressPercent ?? 0;
|
const progressPercent = health?.estimatedCompletionPercent ?? summary?.progressPercent ?? 0;
|
||||||
const showSummaryBlock = hasContent || totalTasks > 0 || tasksFailed > 0 || Boolean(health?.lastActivityAt);
|
const showSummaryBlock = hasContent || totalTasks > 0 || tasksFailed > 0 || Boolean(health?.lastActivityAt);
|
||||||
|
|
||||||
const activeSliceLabel = m.status === "active" && (health?.currentMilestoneId || health?.currentSliceId)
|
|
||||||
? "Current milestone/slice in progress"
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
@@ -3408,6 +3469,8 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
<div className="mission-list__item-header">
|
<div className="mission-list__item-header">
|
||||||
<Target size={16} className="mission-list__item-icon" />
|
<Target size={16} className="mission-list__item-icon" />
|
||||||
<span className="mission-list__item-title">{m.title}</span>
|
<span className="mission-list__item-title">{m.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mission-list__item-tags">
|
||||||
{mission.autopilotEnabled && (
|
{mission.autopilotEnabled && (
|
||||||
<span title="Autopilot enabled"><Zap size={12} className="mission-list__item-autopilot-icon" /></span>
|
<span title="Autopilot enabled"><Zap size={12} className="mission-list__item-autopilot-icon" /></span>
|
||||||
)}
|
)}
|
||||||
@@ -3429,9 +3492,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
{m.description && (
|
{m.description && (
|
||||||
<p className="mission-list__item-description">{m.description}</p>
|
<p className="mission-list__item-description">{m.description}</p>
|
||||||
)}
|
)}
|
||||||
{activeSliceLabel && (
|
|
||||||
<p className="mission-list__item-active-slice">Active: {activeSliceLabel}</p>
|
|
||||||
)}
|
|
||||||
{showSummaryBlock && (
|
{showSummaryBlock && (
|
||||||
<div className="mission-list__item-summary">
|
<div className="mission-list__item-summary">
|
||||||
{hasContent && (
|
{hasContent && (
|
||||||
@@ -3641,11 +3701,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
<div className="mission-list__footer-actions">
|
<div className="mission-list__footer-actions">
|
||||||
<button className="mission-add-btn" onClick={() => setShowInterviewModal(true)}>
|
<button className="mission-add-btn" onClick={() => setShowInterviewModal(true)}>
|
||||||
<Sparkles size={16} />
|
<Sparkles size={16} />
|
||||||
Plan with AI
|
Create New Mission
|
||||||
</button>
|
|
||||||
<button className="mission-add-btn" onClick={handleCreateMission}>
|
|
||||||
<Plus size={16} />
|
|
||||||
New Mission
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -3790,26 +3846,23 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mission-manager__split">
|
<div className="mission-manager__split">
|
||||||
<aside className="mission-manager__sidebar" data-testid="mission-sidebar" aria-label="Mission list">
|
<aside
|
||||||
|
className="mission-manager__sidebar"
|
||||||
|
data-testid="mission-sidebar"
|
||||||
|
aria-label="Mission list"
|
||||||
|
style={isMobile ? undefined : { width: `${sidebarWidth}px` }}
|
||||||
|
>
|
||||||
<div className="mission-manager__sidebar-header">
|
<div className="mission-manager__sidebar-header">
|
||||||
<span className="mission-manager__sidebar-title">Missions</span>
|
<span className="mission-manager__sidebar-title">Missions</span>
|
||||||
<div className="mission-manager__sidebar-actions">
|
<div className="mission-manager__sidebar-actions">
|
||||||
<button
|
<button
|
||||||
className="mission-add-btn mission-add-btn--sm"
|
className="mission-add-btn mission-add-btn--sm"
|
||||||
onClick={() => setShowInterviewModal(true)}
|
onClick={() => setShowInterviewModal(true)}
|
||||||
title="Plan with AI"
|
title="Create New Mission"
|
||||||
aria-label="Plan with AI"
|
aria-label="Create New Mission"
|
||||||
>
|
>
|
||||||
<Sparkles size={14} />
|
<Sparkles size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
className="mission-add-btn mission-add-btn--sm"
|
|
||||||
onClick={handleCreateMission}
|
|
||||||
title="New Mission"
|
|
||||||
aria-label="New Mission"
|
|
||||||
>
|
|
||||||
<Plus size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mission-manager__sidebar-list">
|
<div className="mission-manager__sidebar-list">
|
||||||
@@ -3824,6 +3877,21 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
{!isMobile && (
|
||||||
|
<div
|
||||||
|
className="mission-manager__sidebar-resize-handle"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-valuemin={MISSION_SIDEBAR_MIN_WIDTH}
|
||||||
|
aria-valuemax={MISSION_SIDEBAR_MAX_WIDTH}
|
||||||
|
aria-valuenow={sidebarWidth}
|
||||||
|
aria-label="Resize mission sidebar"
|
||||||
|
tabIndex={0}
|
||||||
|
onPointerDown={handleSidebarResizeStart}
|
||||||
|
onKeyDown={handleSidebarResizeKeyDown}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mission-manager__detail-pane">
|
<div className="mission-manager__detail-pane">
|
||||||
{detailLoading ? (
|
{detailLoading ? (
|
||||||
<div className="mission-manager__loading">
|
<div className="mission-manager__loading">
|
||||||
|
|||||||
Reference in New Issue
Block a user