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 apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
"buffer": "^6.0.3",
"isomorphic-git": "catalog:",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"react-joyride": "^2.9.3"
},
"devDependencies": {
"@happy-dom/global-registrator": "^20.11.2",
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -2243,6 +2243,16 @@ select:disabled,
animation: onboarding-phase-in var(--duration-large, 300ms) var(--ease-out, ease) both;
}

/* The wait phases drop the usual title/subtitle/form stack for the shared
centered loader — a form-column question reads oddly for a step with
nothing to ask. */
.onboarding-phase--loading {
display: flex;
min-height: 20rem;
align-items: center;
justify-content: center;
}

@keyframes onboarding-phase-in {
from {
opacity: 0;
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/chat/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -737,7 +737,7 @@ export const Composer = forwardRef<
}

return (
<div className="chat-composer">
<div className="chat-composer" data-tour="composer">
{mention !== null && (
<div className="chat-mention-popover chat-popover-enter" role="listbox">
{mentionOptions.length === 0 ? (
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/chat/loading-state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
// all, which is what keeps a fast round-trip from flashing chrome the
// reader has no time to read.

import { CorbitsMark } from "@corbits/react-ui";
import { useEffect, useState } from "react";

import { CHAT_STRINGS } from "./strings";
Expand Down Expand Up @@ -82,6 +83,7 @@ export function WorkbenchLoadingState({

return (
<div className={classNames.join(" ")} role="status">
<CorbitsMark decorative className="chat-workbench-loading-brand" />
<span className="chat-workbench-loading-mark" aria-hidden="true">
<span></span>
<span></span>
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/chat/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,28 @@
overflow: auto;
}

.chat-workbench-loading-brand {
height: 2rem;
width: 2rem;
color: var(--shell-accent);
}

@media (prefers-reduced-motion: no-preference) {
.chat-workbench-loading-brand {
animation: chat-workbench-loading-brand-breathe 2.4s ease-in-out infinite;
}
}

@keyframes chat-workbench-loading-brand-breathe {
0%,
100% {
opacity: 0.7;
}
50% {
opacity: 1;
}
}

.chat-workbench-loading-mark {
display: flex;
gap: 0.3rem;
Expand Down
26 changes: 14 additions & 12 deletions apps/web/src/pages/onboarding-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,12 +216,13 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) {
if (state.phase === "checking" || state.phase === "resolving-tenant") {
return (
<OnboardingLayout>
<div className="onboarding-phase" key="checking">
<h1 className="onboarding-title">Checking your workbench</h1>
<p className="onboarding-subtitle">One moment.</p>
<div className="onboarding-content">
<WorkbenchLoadingState delayMs={0} title="Checking your workbench…" />
</div>
<div className="onboarding-phase onboarding-phase--loading" key="checking">
<WorkbenchLoadingState
delayMs={0}
title={
state.phase === "checking" ? "Checking your workbench…" : "Creating your workbench…"
}
/>
</div>
</OnboardingLayout>
);
Expand Down Expand Up @@ -267,12 +268,13 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) {
if (state.phase === "publishing-myra" || state.phase === "installing") {
return (
<OnboardingLayout>
<div className="onboarding-phase" key="installing">
<h1 className="onboarding-title">Setting up your workbench</h1>
<p className="onboarding-subtitle">One moment.</p>
<div className="onboarding-content">
<WorkbenchLoadingState delayMs={0} title="Setting up your workbench…" />
</div>
<div className="onboarding-phase onboarding-phase--loading" key="installing">
<WorkbenchLoadingState
delayMs={0}
title={
state.phase === "publishing-myra" ? "Connecting your model…" : "Getting Myra ready…"
}
/>
</div>
</OnboardingLayout>
);
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/shell/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
} from "./canvas-availability";
import { Sidebar } from "./sidebar";
import { ShellContextMenu } from "./context-menu/shell-context-menu";
import { FirstRunTour } from "./first-run-tour";

const CanvasColumn = lazy(async () => ({
default: (await import("./canvas-column")).CanvasColumn,
Expand Down Expand Up @@ -169,6 +170,7 @@ export function AppShell({
</Suspense>
) : null}
<ShellContextMenu onSignOut={onSignOut} />
<FirstRunTour userId={user.id} />
</div>
);
}
92 changes: 92 additions & 0 deletions apps/web/src/shell/first-run-tour.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// A one-time guided tour of the shell, shown the first time a person lands
// here after setup. "Seen" is a localStorage flag keyed by user id (mirrors
// `command-palette-recents.ts`'s defensive access) so a shared browser
// profile never re-shows it for the wrong account, and finishing or
// skipping both mark it seen for good — there is no "remind me later".

import Joyride, { type CallBackProps, STATUS, type Step } from "react-joyride";
import { useState } from "react";

const STORAGE_PREFIX = "workbench.first-run-tour-seen";

function hasSeenTour(userId: string): boolean {
try {
return window.localStorage.getItem(`${STORAGE_PREFIX}:${userId}`) === "true";
} catch {
return true; // Storage disabled: never nag with a tour that can't remember itself.
}
}

function markTourSeen(userId: string): void {
try {
window.localStorage.setItem(`${STORAGE_PREFIX}:${userId}`, "true");
} catch {
// Storage disabled or full — the tour just replays next visit.
}
}

const STEPS: readonly Step[] = [
{
target: '[data-tour="sidebar-list"]',
title: "Your workbenches",
content: "Every conversation with Myra and your agents lives here.",
placement: "right",
disableBeacon: true,
},
{
target: '[data-tour="new-workbench-button"]',
title: "Start something new",
content: "Spin up a new workbench whenever you need a fresh conversation.",
placement: "bottom",
},
{
target: '[data-tour="composer"]',
title: "Talk to your agent",
content: "Type here to send a message, share files, or @mention another agent.",
placement: "top",
},
{
target: '[data-tour="settings-button"]',
title: "Settings",
content: "Manage providers, credentials, and preferences from here.",
placement: "top",
},
];

/**
* Mounted once from `AppShell`. Renders nothing once the tour has already
* been seen for this user, so it costs nothing on every later visit.
*/
export function FirstRunTour({ userId }: { readonly userId: string }) {
const [run] = useState(() => !hasSeenTour(userId));

if (!run) return null;

function handleCallback(data: CallBackProps) {
if (data.status === STATUS.FINISHED || data.status === STATUS.SKIPPED) {
markTourSeen(userId);
}
}

return (
<Joyride
steps={STEPS as Step[]}
run={run}
continuous
showSkipButton
disableOverlayClose
spotlightPadding={6}
callback={handleCallback}
styles={{
options: {
arrowColor: "var(--card)",
backgroundColor: "var(--card)",
textColor: "var(--foreground)",
overlayColor: "color-mix(in srgb, var(--foreground) 45%, transparent)",
primaryColor: "var(--shell-accent)",
zIndex: 10000,
},
}}
/>
);
}
4 changes: 3 additions & 1 deletion apps/web/src/shell/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,15 @@ export function Sidebar({
aria-label={CHAT_STRINGS.newWorkbenchAction}
title={CHAT_STRINGS.newWorkbenchAction}
onClick={() => onNavigate(NEW_WORKBENCH_PATH)}
data-tour="new-workbench-button"
>
<Plus />
</Button>
</div>
{/* Agents and Channels labels render inside the list, below its
search box (owner's order: logo · search · sections · rows). */}

<SidebarPanelBody>
<SidebarPanelBody data-tour="sidebar-list">
<WorkbenchList path={path} onNavigate={onNavigate} />
</SidebarPanelBody>

Expand Down Expand Up @@ -223,6 +224,7 @@ export function Sidebar({
aria-label="Settings"
title="Settings"
data-active={matchesRoute(SETTINGS_PATH, path) ? "true" : undefined}
data-tour="settings-button"
onClick={() => onNavigate(SETTINGS_PATH)}
>
<SlidersHorizontal />
Expand Down
Loading
Loading