feat: implement mobile app usability improvements

Mobile Navigation:
- Add MobileNav component with bottom tab bar
- Show mobile nav on small screens, hide desktop sidebar
- Add session count badge to Sessions tab
- Add safe area padding for notched devices

Session Management:
- Redesign SessionCard for mobile with action menu
- Add MobileActionSheet for session actions
- Keep primary action prominent

Forms & Dialogs:
- Stack form fields vertically on mobile
- Ensure 44px minimum touch targets
- Update dialogs for 320px viewport

Responsive Layout:
- Add MobilePageHeader with back button
- Reduce page padding on mobile
- Stack multi-column grids vertically

Touch & Interaction:
- Add active states to interactive elements
- Ensure 8px spacing between touch targets

Complex Pages:
- Update Repo Workspace for mobile
- Update Tool Workshop and Config Profiles

Build: TypeScript check passes, production build succeeds
This commit is contained in:
Fusion
2026-05-25 11:20:25 +02:00
parent 8fb4b67372
commit adaedb70ef
18 changed files with 1239 additions and 152 deletions
+193 -95
View File
@@ -1,6 +1,9 @@
import { useState } from "react";
import type { Session } from "../api/sessions";
import { Icon } from "./icon";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { MobileActionSheet } from "./mobile-action-sheet";
import type { IconName } from "./icon";
export interface SessionCardProps {
session: Session;
@@ -45,6 +48,8 @@ export function SessionCard({
}: SessionCardProps) {
const [showStopConfirm, setShowStopConfirm] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showActionSheet, setShowActionSheet] = useState(false);
const isMobile = useMobileViewport();
const status = statusConfig[session.status] || { color: "gray", label: session.status };
const isTerminalOnly = session.tool_type_interfaces?.includes("terminal") && !session.tool_type_interfaces?.includes("web");
@@ -123,118 +128,211 @@ export function SessionCard({
</div>
</div>
<div className="session-card-actions">
{isActive && (
<>
{session.url ? (
<a
href={session.url}
target="_blank"
rel="noopener noreferrer"
className="secondary-button small"
>
<Icon name="external" size="sm" />
<span className="action-label">Open</span>
</a>
) : (
<button
className="secondary-button small"
onClick={() => onOpen?.(session)}
type="button"
disabled={isBusy}
>
<Icon name="external" size="sm" />
<span className="action-label">Open</span>
</button>
)}
{hasTunnelError && onRecreateTunnel && (
<button
className="secondary-button small"
onClick={() => onRecreateTunnel(session)}
type="button"
disabled={isBusy}
>
<Icon name="refresh" size="sm" />
<span className="action-label">Tunnel</span>
</button>
)}
{showStopConfirm ? (
<div className="confirm-inline">
<span className="confirm-text">Stop?</span>
{isMobile ? (
<div className="session-card-actions mobile">
{isActive && (
<>
{session.url ? (
<a
href={session.url}
target="_blank"
rel="noopener noreferrer"
className="secondary-button mobile-primary"
>
<Icon name="external" size="sm" />
Open
</a>
) : (
<button
className="danger-button small"
className="secondary-button mobile-primary"
onClick={() => onOpen?.(session)}
type="button"
disabled={isBusy}
>
<Icon name="external" size="sm" />
Open
</button>
)}
<button
className="ghost-button mobile-more"
onClick={() => setShowActionSheet(true)}
type="button"
disabled={isBusy}
>
<Icon name="menu" size="sm" />
</button>
</>
)}
{!isActive && onStart && (
<button
className="secondary-button mobile-primary"
onClick={() => onStart(session)}
type="button"
disabled={isBusy}
>
<Icon name="play" size="sm" />
Start
</button>
)}
{!isActive && (
<button
className="ghost-button mobile-more"
onClick={() => setShowActionSheet(true)}
type="button"
disabled={isBusy}
>
<Icon name="menu" size="sm" />
</button>
)}
</div>
) : (
<div className="session-card-actions">
{isActive && (
<>
{session.url ? (
<a
href={session.url}
target="_blank"
rel="noopener noreferrer"
className="secondary-button small"
>
<Icon name="external" size="sm" />
<span className="action-label">Open</span>
</a>
) : (
<button
className="secondary-button small"
onClick={() => onOpen?.(session)}
type="button"
disabled={isBusy}
>
<Icon name="external" size="sm" />
<span className="action-label">Open</span>
</button>
)}
{hasTunnelError && onRecreateTunnel && (
<button
className="secondary-button small"
onClick={() => onRecreateTunnel(session)}
type="button"
disabled={isBusy}
>
<Icon name="refresh" size="sm" />
<span className="action-label">Tunnel</span>
</button>
)}
{showStopConfirm ? (
<div className="confirm-inline">
<span className="confirm-text">Stop?</span>
<button
className="danger-button small"
onClick={handleStop}
type="button"
disabled={isBusy}
>
Stop
</button>
<button
className="ghost-button small"
onClick={handleCancelStop}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small"
onClick={handleStop}
type="button"
disabled={isBusy}
>
Stop
<Icon name="stop" size="sm" />
<span className="action-label">Stop</span>
</button>
<button
className="ghost-button small"
onClick={handleCancelStop}
type="button"
>
Cancel
</button>
</div>
) : (
)}
</>
)}
{!isActive && onStart && (
<button
className="secondary-button small"
onClick={() => onStart(session)}
type="button"
disabled={isBusy}
>
<Icon name="play" size="sm" />
<span className="action-label">Start</span>
</button>
)}
{showDeleteConfirm ? (
<div className="confirm-inline">
<span className="confirm-text">Delete?</span>
<button
className="ghost-button small"
onClick={handleStop}
className="danger-button small"
onClick={handleDelete}
type="button"
disabled={isBusy}
>
<Icon name="stop" size="sm" />
<span className="action-label">Stop</span>
Delete
</button>
)}
</>
)}
{!isActive && onStart && (
<button
className="secondary-button small"
onClick={() => onStart(session)}
type="button"
disabled={isBusy}
>
<Icon name="play" size="sm" />
<span className="action-label">Start</span>
</button>
)}
{showDeleteConfirm ? (
<div className="confirm-inline">
<span className="confirm-text">Delete?</span>
<button
className="ghost-button small"
onClick={handleCancelDelete}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="danger-button small"
className="ghost-button small danger-text"
onClick={handleDelete}
type="button"
disabled={isBusy}
>
Delete
<Icon name="delete" size="sm" />
</button>
<button
className="ghost-button small"
onClick={handleCancelDelete}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small danger-text"
onClick={handleDelete}
type="button"
disabled={isBusy}
>
<Icon name="delete" size="sm" />
</button>
)}
</div>
)}
</div>
)}
<MobileActionSheet
isOpen={showActionSheet}
onClose={() => setShowActionSheet(false)}
title={session.display_name}
actions={[
...(isActive && hasTunnelError && onRecreateTunnel
? [{
id: "tunnel",
label: "Recreate Tunnel",
icon: "refresh" as IconName,
onClick: () => onRecreateTunnel(session),
}]
: []),
...(isActive && onStop
? [{
id: "stop",
label: "Stop",
icon: "stop" as IconName,
variant: "danger" as const,
onClick: () => onStop(session),
}]
: []),
...(onDelete
? [{
id: "delete",
label: "Delete",
icon: "delete" as IconName,
variant: "danger" as const,
onClick: () => onDelete(session),
}]
: []),
]}
/>
</article>
);
}