diff --git a/app/src/components/projects/ContainerProgressModal.tsx b/app/src/components/projects/ContainerProgressModal.tsx new file mode 100644 index 0000000..0f384d8 --- /dev/null +++ b/app/src/components/projects/ContainerProgressModal.tsx @@ -0,0 +1,109 @@ +import { useEffect, useRef, useCallback } from "react"; + +interface Props { + projectName: string; + operation: "starting" | "stopping" | "resetting"; + progressMsg: string | null; + error: string | null; + completed: boolean; + onForceStop: () => void; + onClose: () => void; +} + +const operationLabels: Record = { + starting: "Starting", + stopping: "Stopping", + resetting: "Resetting", +}; + +export default function ContainerProgressModal({ + projectName, + operation, + progressMsg, + error, + completed, + onForceStop, + onClose, +}: Props) { + const overlayRef = useRef(null); + + // Auto-close on success after 800ms + useEffect(() => { + if (completed && !error) { + const timer = setTimeout(onClose, 800); + return () => clearTimeout(timer); + } + }, [completed, error, onClose]); + + // Escape to close (only when completed or error) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && (completed || error)) onClose(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [completed, error, onClose]); + + const handleOverlayClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === overlayRef.current && (completed || error)) onClose(); + }, + [completed, error, onClose], + ); + + const inProgress = !completed && !error; + + return ( +
+
+

+ {operationLabels[operation]} “{projectName}” +

+ + {/* Spinner / checkmark / error icon */} +
+ {error ? ( + + ) : completed ? ( + + ) : ( +
+ )} +
+ + {/* Progress message */} +

+ {error + ? {error} + : completed + ? "Done!" + : progressMsg ?? `${operationLabels[operation]}...`} +

+ + {/* Buttons */} +
+ {inProgress && ( + + )} + {(completed || error) && ( + + )} +
+
+
+ ); +} diff --git a/app/src/components/projects/ProjectCard.tsx b/app/src/components/projects/ProjectCard.tsx index b78ecde..50ce254 100644 --- a/app/src/components/projects/ProjectCard.tsx +++ b/app/src/components/projects/ProjectCard.tsx @@ -8,6 +8,7 @@ import { useAppState } from "../../store/appState"; import EnvVarsModal from "./EnvVarsModal"; import PortMappingsModal from "./PortMappingsModal"; import ClaudeInstructionsModal from "./ClaudeInstructionsModal"; +import ContainerProgressModal from "./ContainerProgressModal"; interface Props { project: Project; @@ -25,6 +26,8 @@ export default function ProjectCard({ project }: Props) { const [showPortMappingsModal, setShowPortMappingsModal] = useState(false); const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false); const [progressMsg, setProgressMsg] = useState(null); + const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null); + const [operationCompleted, setOperationCompleted] = useState(false); const isSelected = selectedProjectId === project.id; const isStopped = project.status === "stopped" || project.status === "error"; @@ -79,16 +82,25 @@ export default function ProjectCard({ project }: Props) { return () => { unlisten.then((f) => f()); }; }, [project.id]); - // Clear progress when status settles + // Mark operation completed when status settles useEffect(() => { if (project.status === "running" || project.status === "stopped" || project.status === "error") { - setProgressMsg(null); + if (activeOperation) { + setOperationCompleted(true); + } + // Clear progress if no modal is managing it + if (!activeOperation) { + setProgressMsg(null); + } } - }, [project.status]); + }, [project.status, activeOperation]); const handleStart = async () => { setLoading(true); setError(null); + setProgressMsg(null); + setOperationCompleted(false); + setActiveOperation("starting"); try { await start(project.id); } catch (e) { @@ -101,6 +113,9 @@ export default function ProjectCard({ project }: Props) { const handleStop = async () => { setLoading(true); setError(null); + setProgressMsg(null); + setOperationCompleted(false); + setActiveOperation("stopping"); try { await stop(project.id); } catch (e) { @@ -118,6 +133,21 @@ export default function ProjectCard({ project }: Props) { } }; + const handleForceStop = async () => { + try { + await stop(project.id); + } catch (e) { + setError(String(e)); + } + }; + + const closeModal = () => { + setActiveOperation(null); + setOperationCompleted(false); + setProgressMsg(null); + setError(null); + }; + const defaultBedrockConfig: BedrockConfig = { auth_method: "static_credentials", aws_region: "us-east-1", @@ -324,6 +354,10 @@ export default function ProjectCard({ project }: Props) { { setLoading(true); + setError(null); + setProgressMsg(null); + setOperationCompleted(false); + setActiveOperation("resetting"); try { await rebuild(project.id); } catch (e) { setError(String(e)); } setLoading(false); }} @@ -747,6 +781,18 @@ export default function ProjectCard({ project }: Props) { onClose={() => setShowClaudeInstructionsModal(false)} /> )} + + {activeOperation && ( + + )}
); } diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index 6725dc4..421d6db 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -25,6 +25,7 @@ export default function TerminalView({ sessionId, active }: Props) { const [detectedUrl, setDetectedUrl] = useState(null); const [imagePasteMsg, setImagePasteMsg] = useState(null); + const [isAtBottom, setIsAtBottom] = useState(true); useEffect(() => { if (!containerRef.current) return; @@ -86,6 +87,12 @@ export default function TerminalView({ sessionId, active }: Props) { sendInput(sessionId, data); }); + // Track scroll position to show "Jump to Current" button + const scrollDisposable = term.onScroll(() => { + const buf = term.buffer.active; + setIsAtBottom(buf.viewportY >= buf.baseY); + }); + // Handle image paste: intercept paste events with image data, // upload to the container, and inject the file path into terminal input. const handlePaste = (e: ClipboardEvent) => { @@ -164,6 +171,7 @@ export default function TerminalView({ sessionId, active }: Props) { detector.dispose(); detectorRef.current = null; inputDisposable.dispose(); + scrollDisposable.dispose(); containerRef.current?.removeEventListener("paste", handlePaste, { capture: true }); outputPromise.then((fn) => fn?.()); exitPromise.then((fn) => fn?.()); @@ -231,6 +239,11 @@ export default function TerminalView({ sessionId, active }: Props) { } }, [detectedUrl]); + const handleScrollToBottom = useCallback(() => { + termRef.current?.scrollToBottom(); + setIsAtBottom(true); + }, []); + return (
)} + {!isAtBottom && ( + + )}
{ + const { projects } = useAppState.getState(); + const project = projects.find((p) => p.id === id); + if (project) { + updateProjectInList({ ...project, status }); + } + }, + [updateProjectInList], + ); + const start = useCallback( async (id: string) => { + setOptimisticStatus(id, "starting"); const updated = await commands.startProjectContainer(id); updateProjectInList(updated); return updated; }, - [updateProjectInList], + [updateProjectInList, setOptimisticStatus], ); const stop = useCallback( async (id: string) => { + setOptimisticStatus(id, "stopping"); await commands.stopProjectContainer(id); const list = await commands.listProjects(); setProjects(list); }, - [setProjects], + [setProjects, setOptimisticStatus], ); const rebuild = useCallback( async (id: string) => { + setOptimisticStatus(id, "starting"); const updated = await commands.rebuildProjectContainer(id); updateProjectInList(updated); return updated; }, - [updateProjectInList], + [updateProjectInList, setOptimisticStatus], ); const update = useCallback(