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
1 change: 1 addition & 0 deletions apps/desktop/dist/assets/index-B5v3W6Qd.css

Large diffs are not rendered by default.

9 changes: 0 additions & 9 deletions apps/desktop/dist/assets/index-BfOOoQLP.js

This file was deleted.

1 change: 0 additions & 1 deletion apps/desktop/dist/assets/index-BlDh6L6x.css

This file was deleted.

9 changes: 9 additions & 0 deletions apps/desktop/dist/assets/index-CmJQg31q.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions apps/desktop/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Instrument+Serif:ital@0;1&display=swap"
/>
<script type="module" crossorigin src="/assets/index-BfOOoQLP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BlDh6L6x.css">
<script type="module" crossorigin src="/assets/index-CmJQg31q.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B5v3W6Qd.css">
</head>
<body>
<div id="root"></div>
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"packageManager": "bun@1.3.13",
"scripts": {
"dev": "tauri dev",
"dev:first-launch": "VITE_STAY_FORCE_LAUNCH_INTRO=1 tauri dev",
"dev:worktree": "bash ../../scripts/stay-worktree-dev.sh",
"frontend:dev": "vite --host 127.0.0.1",
"frontend:build": "vite build",
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,17 @@ fn set_guarding_panel_expanded(
result.map_err(|error| error.to_string())
}

#[tauri::command]
fn set_launch_animation_active(active: bool, window: WebviewWindow) -> Result<(), String> {
let result = if active {
position_launch_animation(&window)
} else {
position_top_right(&window)
};

result.map_err(|error| error.to_string())
}

#[tauri::command]
fn observe_focus_for_test(
app_name: String,
Expand Down Expand Up @@ -255,6 +266,7 @@ pub fn run() {
stop_guarding,
submit_pin,
set_guarding_panel_expanded,
set_launch_animation_active,
observe_focus_for_test
])
.run(tauri::generate_context!())
Expand Down Expand Up @@ -304,6 +316,18 @@ fn position_guarding_handle(window: &WebviewWindow) -> tauri::Result<()> {
Ok(())
}

fn position_launch_animation(window: &WebviewWindow) -> tauri::Result<()> {
let Some(monitor) = window.current_monitor()? else {
return Ok(());
};

window.set_position(tauri::Position::Physical(*monitor.position()))?;
window.set_size(tauri::Size::Physical(*monitor.size()))?;
window.show()?;
window.set_focus()?;
Ok(())
}

fn compact_window_geometry(
monitor_position: PhysicalPosition<i32>,
monitor_size: PhysicalSize<u32>,
Expand Down
42 changes: 35 additions & 7 deletions apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { App } from "./App";
import { createMockStayClient } from "./mockStayClient";

function renderApp(client = createMockStayClient()) {
return render(<App client={client} showLaunchIntro={false} />);
}

describe("Stay GUI", () => {
it("shows PIN setup in idle mode and constrains PIN input", async () => {
const user = userEvent.setup();
const client = createMockStayClient();

render(<App client={client} />);
renderApp(client);

const pinInput = await screen.findByLabelText("Set a four digit PIN");
const keepButton = screen.getByRole("button", { name: "Keep" });
Expand All @@ -34,7 +38,7 @@ describe("Stay GUI", () => {
await client.setPin("4821");
client.focusMeeting();

render(<App client={client} />);
renderApp(client);

expect(await screen.findByText("Stay with this meeting?")).toBeInTheDocument();
expect(screen.getByText("Weekly Team Sync")).toBeInTheDocument();
Expand All @@ -51,7 +55,7 @@ describe("Stay GUI", () => {
await client.setPin("4821");
client.focusMeeting();

render(<App client={client} />);
renderApp(client);

await user.click(await screen.findByRole("button", { name: "Stay" }));

Expand All @@ -73,7 +77,7 @@ describe("Stay GUI", () => {
await client.acceptStay();
client.focusAway();

render(<App client={client} />);
renderApp(client);

const unlockInput = await screen.findByLabelText("Unlock PIN");
const openButton = screen.getByRole("button", { name: "Open" });
Expand All @@ -95,7 +99,7 @@ describe("Stay GUI", () => {
const client = createMockStayClient();
await client.setPin("4821");

render(<App client={client} />);
renderApp(client);

expect(await screen.findByText("Stay is waiting.")).toBeInTheDocument();

Expand All @@ -111,7 +115,7 @@ describe("Stay GUI", () => {
const client = createMockStayClient();
client.focusMeeting();

render(<App client={client} />);
renderApp(client);

expect(await screen.findByText("Stay with this meeting?")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Stay" })).toBeDisabled();
Expand All @@ -127,4 +131,28 @@ describe("Stay GUI", () => {
expect(screen.queryByText("PIN must be exactly four digits")).not.toBeInTheDocument();
expect(screen.getByText("Stay with this meeting?")).toBeInTheDocument();
});

it("shows the first-launch intro when enabled and dismisses it", async () => {
const user = userEvent.setup();

render(<App client={createMockStayClient()} showLaunchIntro={true} />);

expect(await screen.findByLabelText("Stay opening animation")).toBeInTheDocument();

await user.click(screen.getByRole("button", { name: "Skip" }));

expect(screen.queryByLabelText("Stay opening animation")).not.toBeInTheDocument();
});

it("can force the first-launch intro in dev with an env flag", async () => {
vi.stubEnv("VITE_STAY_FORCE_LAUNCH_INTRO", "1");

try {
render(<App client={createMockStayClient()} />);

expect(await screen.findByLabelText("Stay opening animation")).toBeInTheDocument();
} finally {
vi.unstubAllEnvs();
}
});
});
168 changes: 120 additions & 48 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,41 @@
import { useEffect, useMemo, useState } from "react";
import { invoke, isTauri } from "@tauri-apps/api/core";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { CommandResponse, GuardView } from "./types";
import type { StayClient } from "./stayClient";
import { errorMessage } from "./stayClient";
import { GuardingStatus } from "./ui/GuardingStatus";
import { LaunchIntro } from "./ui/LaunchIntro";
import { LockScreen } from "./ui/LockScreen";
import { MeetingPrompt } from "./ui/MeetingPrompt";
import { PinSetup } from "./ui/PinSetup";
import { StatusShell } from "./ui/StatusShell";

const launchIntroStorageKey = "stay.launchIntro.v1.seen";
const launchIntroPreviewParam = "launchIntro";
const forceLaunchIntroEnv = "1";

const idleView: GuardView = {
mode: "idle",
pin_configured: false,
};

type AppProps = {
client: StayClient;
showLaunchIntro?: boolean;
};

type ActionError = {
target: "setup" | "mode";
message: string;
};

export function App({ client }: AppProps) {
export function App({ client, showLaunchIntro: showLaunchIntroOverride }: AppProps) {
const [view, setView] = useState<GuardView>(idleView);
const [isLoading, setIsLoading] = useState(true);
const [actionError, setActionError] = useState<ActionError | null>(null);
const [showLaunchIntro, setShowLaunchIntro] = useState(() =>
showLaunchIntroOverride ?? shouldShowLaunchIntro(),
);

useEffect(() => {
let isMounted = true;
Expand Down Expand Up @@ -73,6 +83,18 @@ export function App({ client }: AppProps) {
};
}, [client]);

useEffect(() => {
if (!showLaunchIntro) {
return;
}

void setLaunchAnimationActive(true);

return () => {
void setLaunchAnimationActive(false);
};
}, [showLaunchIntro]);

const status = useMemo(() => statusForView(view, isLoading), [isLoading, view]);

async function runCommand(
Expand All @@ -92,55 +114,65 @@ export function App({ client }: AppProps) {

const setupError = actionError?.target === "setup" ? actionError.message : null;
const modeError = actionError?.target === "mode" ? actionError.message : null;
const completeLaunchIntro = useCallback(() => {
markLaunchIntroSeen();
setShowLaunchIntro(false);
}, []);

return (
<StatusShell mode={view.mode} status={status}>
{isLoading ? (
<section className="mode-panel quiet-state">
<p className="kicker">Opening</p>
<h2>Stay is opening.</h2>
</section>
) : null}

{!isLoading && !view.pin_configured ? (
<PinSetup error={setupError} onSave={(pin) => runCommand(() => client.setPin(pin), "setup")} />
) : null}

{!isLoading && view.mode === "idle" && view.pin_configured ? (
<section className="mode-panel quiet-state">
<p className="kicker">Ready</p>
<h2>Stay is waiting.</h2>
</section>
) : null}

{!isLoading && view.mode === "meeting_candidate" ? (
<MeetingPrompt
candidate={view.candidate}
canAccept={view.pin_configured}
error={modeError}
onAccept={() => {
void runCommand(() => client.acceptStay());
}}
onDismiss={() => {
void runCommand(() => client.dismissCandidate());
}}
/>
) : null}

{!isLoading && view.mode === "guarding" ? (
<GuardingStatus
meeting={view.meeting}
error={modeError}
onStop={() => {
void runCommand(() => client.stopGuarding());
}}
/>
) : null}

{!isLoading && view.mode === "locked" ? (
<LockScreen view={view} error={modeError} onSubmit={(pin) => runCommand(() => client.submitPin(pin))} />
<>
<StatusShell mode={view.mode} status={status}>
{isLoading ? (
<section className="mode-panel quiet-state">
<p className="kicker">Opening</p>
<h2>Stay is opening.</h2>
</section>
) : null}

{!isLoading && !view.pin_configured ? (
<PinSetup error={setupError} onSave={(pin) => runCommand(() => client.setPin(pin), "setup")} />
) : null}

{!isLoading && view.mode === "idle" && view.pin_configured ? (
<section className="mode-panel quiet-state">
<p className="kicker">Ready</p>
<h2>Stay is waiting.</h2>
</section>
) : null}

{!isLoading && view.mode === "meeting_candidate" ? (
<MeetingPrompt
candidate={view.candidate}
canAccept={view.pin_configured}
error={modeError}
onAccept={() => {
void runCommand(() => client.acceptStay());
}}
onDismiss={() => {
void runCommand(() => client.dismissCandidate());
}}
/>
) : null}

{!isLoading && view.mode === "guarding" ? (
<GuardingStatus
meeting={view.meeting}
error={modeError}
onStop={() => {
void runCommand(() => client.stopGuarding());
}}
/>
) : null}

{!isLoading && view.mode === "locked" ? (
<LockScreen view={view} error={modeError} onSubmit={(pin) => runCommand(() => client.submitPin(pin))} />
) : null}
</StatusShell>

{showLaunchIntro ? (
<LaunchIntro onComplete={completeLaunchIntro} />
) : null}
</StatusShell>
</>
);
}

Expand All @@ -164,3 +196,43 @@ function statusForView(view: GuardView, isLoading: boolean): string {
return "Locked";
}
}

function shouldShowLaunchIntro(): boolean {
if (isLaunchIntroForced()) {
return true;
}

if (new URLSearchParams(window.location.search).get(launchIntroPreviewParam) === "1") {
return true;
}

try {
return window.localStorage.getItem(launchIntroStorageKey) !== "true";
} catch {
return true;
}
}

function isLaunchIntroForced(): boolean {
return import.meta.env.DEV && import.meta.env.VITE_STAY_FORCE_LAUNCH_INTRO === forceLaunchIntroEnv;
}

function markLaunchIntroSeen(): void {
try {
window.localStorage.setItem(launchIntroStorageKey, "true");
} catch {
// Storage can be unavailable in locked-down webviews; the intro still finishes for this session.
}
}

async function setLaunchAnimationActive(active: boolean): Promise<void> {
if (!isTauri()) {
return;
}

try {
await invoke("set_launch_animation_active", { active });
} catch {
// The visual animation is still usable if the native window resize is unavailable.
}
}
Loading
Loading