diff --git a/frontend/app/onboarding/onboarding-command.tsx b/frontend/app/onboarding/onboarding-command.tsx index d98065dbf6..9cf8c2aae9 100644 --- a/frontend/app/onboarding/onboarding-command.tsx +++ b/frontend/app/onboarding/onboarding-command.tsx @@ -1,8 +1,9 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { useCallback, useLayoutEffect, useRef, useState } from "react"; import { FakeBlock } from "./onboarding-layout"; +import { FakeTermBlock } from "./onboarding-layout-term"; import waveLogo from "/logos/wave-logo.png"; export type CommandRevealProps = { diff --git a/frontend/app/onboarding/onboarding-durable.tsx b/frontend/app/onboarding/onboarding-durable.tsx index 9a4286f8fd..b716b3d7da 100644 --- a/frontend/app/onboarding/onboarding-durable.tsx +++ b/frontend/app/onboarding/onboarding-durable.tsx @@ -8,6 +8,7 @@ import { TabRpcClient } from "@/app/store/wshrpcutil"; import { useState } from "react"; import { CurrentOnboardingVersion } from "./onboarding-common"; import { OnboardingFooter } from "./onboarding-features-footer"; +import { TailDeployLogCommand } from "./onboarding-layout-term"; export const DurableSessionPage = ({ onNext, @@ -42,11 +43,11 @@ export const DurableSessionPage = ({
Durable SSH Sessions
-
-
-
+
+
+
- Your SSH Sessions, Protected + SSH Sessions, Protected
@@ -64,7 +65,7 @@ export const DurableSessionPage = ({
-

Buffered output streams back in — you never miss a line

+

Buffered output streams back in, never miss a line

@@ -77,46 +78,8 @@ export const DurableSessionPage = ({

-
-
-
Session States
- -
- -
-
Attached
-
Session is protected and connected
-
-
- -
- -
-
Detached
-
Session running, currently disconnected
-
-
- -
- -
-
Standard
-
Connection drops will end the session
-
-
- -
-
-
Common use cases:
-
    -
  • • Alternative to tmux or screen
  • -
  • • Long-running builds and deployments
  • -
  • • Working from unstable networks
  • -
  • • Surviving Wave restarts
  • -
-
-
-
+
+
diff --git a/frontend/app/onboarding/onboarding-layout-term.tsx b/frontend/app/onboarding/onboarding-layout-term.tsx new file mode 100644 index 0000000000..95a4bde9e9 --- /dev/null +++ b/frontend/app/onboarding/onboarding-layout-term.tsx @@ -0,0 +1,262 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { MagnifyIcon } from "@/app/element/magnify"; +import { cn, makeIconClass } from "@/util/util"; +import { useCallback, useLayoutEffect, useState } from "react"; +import { CommandReveal } from "./onboarding-command"; + +export type FakeTermBlockProps = { + connectionName?: string; + durableStatus?: "connected" | "detached" | null; + className?: string; + command?: string; + typeIntervalMs?: number; + onComplete?: () => void; + children?: React.ReactNode; +}; + +export const FakeTermBlock = ({ + connectionName = "ubuntu@remoteserver", + durableStatus = null, + className, + command, + typeIntervalMs = 80, + onComplete, + children, +}: FakeTermBlockProps) => { + const color = "var(--conn-icon-color-1)"; + + const durableIconColor = durableStatus === "connected" ? "text-sky-500" : "text-sky-300"; + + return ( +
+
+
+ + + +
{connectionName}
+
+ {durableStatus && ( +
+ +
+ )} +
+ + + + +
+
+ {children ? ( + children + ) : command ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+ ); +}; + +const deployMessages = [ + "[1/8] Installing dependencies...", + "[2/8] Generating TypeScript types from Go...", + "[3/8] Building Go backend (wavesrv)...", + "[4/8] Compiling TypeScript frontend...", + "[5/8] Bundling Electron renderer...", + "[6/8] Packaging application artifacts...", + "[7/8] Code signing binaries...", + "[8/8] Deploy complete ✓", +]; + +type OverlayState = null | "disconnected" | "connected"; + +const ConnectionOverlay = ({ state }: { state: OverlayState }) => { + if (!state) return null; + + const isConnected = state === "connected"; + + return ( +
+
+ +
+ {isConnected ? "Connected" : "Disconnected"} +
+
+
+ ); +}; + +const DeployLogOutput = ({ + onComplete, + onOverlayStateChange, +}: { + onComplete?: () => void; + onOverlayStateChange?: (state: OverlayState) => void; +}) => { + const [key, setKey] = useState(0); + const [commandComplete, setCommandComplete] = useState(false); + const [visibleLines, setVisibleLines] = useState(0); + const [showPrompt, setShowPrompt] = useState(false); + const [showCursor, setShowCursor] = useState(false); + const [overlayState, setOverlayState] = useState(null); + + useLayoutEffect(() => { + if (onOverlayStateChange) { + onOverlayStateChange(overlayState); + } + }, [overlayState, onOverlayStateChange]); + + const handleCommandComplete = useCallback(() => { + setCommandComplete(true); + }, []); + + const resetAnimation = useCallback(() => { + setCommandComplete(false); + setVisibleLines(0); + setShowPrompt(false); + setShowCursor(false); + setOverlayState(null); + setKey((prev) => prev + 1); + }, []); + + useLayoutEffect(() => { + if (!commandComplete) return; + + let timeoutId: NodeJS.Timeout; + + const runSequence = async () => { + // Show message 1 + setVisibleLines(1); + await new Promise((resolve) => { + timeoutId = setTimeout(resolve, 1000); + }); + + // Show message 2 + setVisibleLines(2); + await new Promise((resolve) => { + timeoutId = setTimeout(resolve, 1000); + }); + + // Show disconnected overlay + setOverlayState("disconnected"); + await new Promise((resolve) => { + timeoutId = setTimeout(resolve, 2500); + }); + + // Change to connected + setOverlayState("connected"); + await new Promise((resolve) => { + timeoutId = setTimeout(resolve, 1000); + }); + + // Remove overlay and show messages 3-7 instantly + setOverlayState(null); + setVisibleLines(7); + + // Show message 8 + setVisibleLines(8); + await new Promise((resolve) => { + timeoutId = setTimeout(resolve, 1000); + }); + + // Show prompt + setShowPrompt(true); + setShowCursor(true); + if (onComplete) { + onComplete(); + } + + // Wait 6 seconds then restart + await new Promise((resolve) => { + timeoutId = setTimeout(resolve, 6000); + }); + + resetAnimation(); + }; + + runSequence(); + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [commandComplete, onComplete, resetAnimation]); + + useLayoutEffect(() => { + if (!showPrompt) return; + + const cursorInterval = setInterval(() => { + setShowCursor((prev) => !prev); + }, 500); + + return () => clearInterval(cursorInterval); + }, [showPrompt]); + + return ( + <> +
+ + {commandComplete && ( + <> + {deployMessages.slice(0, visibleLines).map((msg, idx) => ( +
+ {msg} +
+ ))} + {showPrompt && ( +
+ > + {showCursor && ( + + )} +
+ )} + + )} +
+ {overlayState && } + + ); +}; + +export const TailDeployLogCommand = ({ onComplete }: { onComplete?: () => void }) => { + const [overlayState, setOverlayState] = useState(null); + + const durableStatus = overlayState === "disconnected" ? "detached" : "connected"; + + return ( + + + + ); +};