Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion frontend/app/onboarding/onboarding-command.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
53 changes: 8 additions & 45 deletions frontend/app/onboarding/onboarding-durable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -42,11 +43,11 @@ export const DurableSessionPage = ({
<div className="text-[25px] font-normal text-foreground">Durable SSH Sessions</div>
</header>
<div className="flex-1 flex flex-row gap-0 min-h-0">
<div className="flex-1 flex flex-col items-center justify-center gap-8 pr-6 unselectable">
<div className="flex flex-col items-start gap-6 max-w-md">
<div className="flex h-[52px] ml-[-4px] pl-3 pr-4 items-center rounded-lg bg-hover text-[18px]">
<div className="flex-1 flex flex-col items-center justify-center gap-8 pr-3 unselectable">
<div className="flex flex-col items-start gap-3 max-w-md">
<div className="flex h-[52px] ml-[-4px] pl-3 pr-3 items-center rounded-lg bg-hover text-[15px]">
<i className="fa-sharp fa-solid fa-shield text-sky-500" />
<span className="font-bold ml-2 text-primary">Your SSH Sessions, Protected</span>
<span className="font-bold ml-2 text-primary">SSH Sessions, Protected</span>
</div>

<div className="flex flex-col items-start gap-4 text-secondary">
Expand All @@ -64,7 +65,7 @@ export const DurableSessionPage = ({

<div className="flex items-start gap-3 w-full">
<i className="fa-sharp fa-solid fa-box text-accent text-lg mt-1 flex-shrink-0" />
<p>Buffered output streams back in — you never miss a line</p>
<p>Buffered output streams back in, never miss a line</p>
</div>

<p className="italic">
Expand All @@ -77,46 +78,8 @@ export const DurableSessionPage = ({
</div>
</div>
<div className="w-[2px] bg-border flex-shrink-0"></div>
<div className="flex items-center justify-center pl-6 flex-shrink-0 w-[400px]">
<div className="flex flex-col gap-6 text-secondary">
<div className="text-lg font-semibold text-foreground">Session States</div>

<div className="flex items-start gap-3">
<i className="fa-sharp fa-solid fa-shield text-sky-500 text-xl mt-0.5" />
<div>
<div className="font-semibold text-foreground">Attached</div>
<div className="text-sm">Session is protected and connected</div>
</div>
</div>

<div className="flex items-start gap-3">
<i className="fa-sharp fa-solid fa-shield text-sky-300 text-xl mt-0.5" />
<div>
<div className="font-semibold text-foreground">Detached</div>
<div className="text-sm">Session running, currently disconnected</div>
</div>
</div>

<div className="flex items-start gap-3">
<i className="fa-sharp fa-regular fa-shield text-muted text-xl mt-0.5" />
<div>
<div className="font-semibold text-foreground">Standard</div>
<div className="text-sm">Connection drops will end the session</div>
</div>
</div>

<div className="mt-4 p-4 bg-hover rounded-lg border border-border/50">
<div className="text-sm">
<div className="font-semibold text-foreground mb-2">Common use cases:</div>
<ul className="space-y-1.5 ml-2">
<li>• Alternative to tmux or screen</li>
<li>• Long-running builds and deployments</li>
<li>• Working from unstable networks</li>
<li>• Surviving Wave restarts</li>
</ul>
</div>
</div>
</div>
<div className="flex items-center justify-center pl-6 flex-shrink-0 w-[500px]">
<TailDeployLogCommand />
</div>
</div>
<OnboardingFooter currentStep={2} totalSteps={4} onNext={onNext} onPrev={onPrev} onSkip={onSkip} />
Expand Down
262 changes: 262 additions & 0 deletions frontend/app/onboarding/onboarding-layout-term.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={cn(
"w-full h-full bg-background rounded flex flex-col overflow-hidden border-2 border-accent",
className
)}
>
<div className="flex items-center gap-2 px-2 py-1.5 bg-border/20 border-b border-border/50 pl-[2px]">
<div className="group flex items-center flex-nowrap overflow-hidden text-ellipsis min-w-0 font-normal text-primary rounded-sm">
<span className="fa-stack flex-[1_1_auto] overflow-hidden">
<i
className={cn(makeIconClass("arrow-right-arrow-left", false), "fa-stack-1x mr-[2px]")}
style={{ color: color }}
/>
</span>
<div className="flex-[1_2_auto] overflow-hidden pr-1 ellipsis">{connectionName}</div>
</div>
{durableStatus && (
<div className="iconbutton disabled text-[13px] ml-[-4px]">
<i className={`fa-sharp fa-solid fa-shield ${durableIconColor}`} />
</div>
)}
<div className="flex-1" />
<span className="inline-block [&_svg]:fill-foreground/50 [&_svg_path]:!fill-foreground/50">
<MagnifyIcon enabled={false} />
</span>
<i className={makeIconClass("xmark-large", false) + " text-xs text-foreground/50"} />
</div>
<div className="flex-1 overflow-auto p-4">
{children ? (
children
) : command ? (
<div className="font-mono text-sm">
<CommandReveal command={command} typeIntervalMs={typeIntervalMs} onComplete={onComplete} />
</div>
) : (
<div className="flex items-center justify-center h-full">
<i className={makeIconClass("terminal", false) + " text-4xl text-foreground/50"} />
</div>
)}
</div>
</div>
);
};

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 (
<div className="absolute inset-0 flex items-center justify-center z-10">
<div className="bg-white/20 backdrop-blur-[2px] rounded-lg flex flex-col items-center justify-center gap-4 px-12 py-8 w-[50%]">
<i
className={cn(
"fa-sharp fa-solid",
isConnected ? "fa-wifi text-green-400" : "fa-wifi-slash text-red-400",
"text-6xl"
)}
/>
<div className="text-2xl font-semibold text-foreground">
{isConnected ? "Connected" : "Disconnected"}
</div>
</div>
</div>
);
};

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<OverlayState>(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 (
<>
<div className="font-mono text-sm flex flex-col gap-1">
<CommandReveal
key={key}
command="tail -f deploy.log"
typeIntervalMs={80}
onComplete={handleCommandComplete}
/>
{commandComplete && (
<>
{deployMessages.slice(0, visibleLines).map((msg, idx) => (
<div key={idx} className="text-foreground/70">
{msg}
</div>
))}
{showPrompt && (
<div className="flex items-center gap-2">
<span className="text-accent">&gt;</span>
{showCursor && (
<span className="inline-block w-2 h-4 bg-foreground/80 align-middle"></span>
)}
</div>
)}
</>
)}
</div>
{overlayState && <ConnectionOverlay state={overlayState} />}
</>
);
};

export const TailDeployLogCommand = ({ onComplete }: { onComplete?: () => void }) => {
const [overlayState, setOverlayState] = useState<OverlayState>(null);

const durableStatus = overlayState === "disconnected" ? "detached" : "connected";

return (
<FakeTermBlock connectionName="ubuntu@remoteserver" durableStatus={durableStatus} className="relative">
<DeployLogOutput onComplete={onComplete} onOverlayStateChange={setOverlayState} />
</FakeTermBlock>
);
};
Loading