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 app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@
"@sproutgit/ui": "workspace:*",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-router": "^1.170.16",
"driver.js": "^1.6.0",
"electron-log": "^5.4.4",
"electron-updater": "^6.8.9",
"highlight.js": "^11.11.1",
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,11 @@ function createWindow(): BrowserWindow {
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
// Electron renderer processes don't inherit the main process's argv —
// additionalArguments is the documented way to forward a flag onto the
// renderer/preload process.argv (used by app/src/preload/index.ts to
// expose api.isE2E without a round-trip IPC call).
...(isE2EMode && { additionalArguments: ['--sproutgit-e2e'] }),
},
});

Expand Down
7 changes: 7 additions & 0 deletions app/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ function invoke<K extends keyof IpcMap>(
* to Node.js or the Electron internals.
*/
const api = {
// ── Environment ───────────────────────────────────────────────────────────
// Static (not IPC) — preload has direct Node access even under context
// isolation, so this reads argv locally instead of round-tripping to main.
// Lets renderer-only behavior (e.g. the onboarding tour) opt out of
// WebdriverIO's shared, long-lived session without a dedicated IPC channel.
isE2E: process.argv.includes('--sproutgit-e2e'),

// ── Git info ──────────────────────────────────────────────────────────────
gitInfo: (): Promise<GitInfo> =>
invoke(IPC.GIT_INFO),
Expand Down
90 changes: 90 additions & 0 deletions app/src/renderer/onboarding/homeTour.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { driver, type Driver } from 'driver.js';
import 'driver.js/dist/driver.css';
import './tour-theme.css';

/** Settings-DB key: presence (any value) means the user has seen or dismissed the tour. */
export const HOME_TOUR_SETTING_KEY = 'onboardingHomeTourDismissed';

/**
* Builds and starts the first-run walkthrough over the home screen, ending
* with a step explaining the worktree-first model (worktree → agent) that
* picks up once a workspace is open. `onDestroyed` fires for every exit path
* (Done, Escape, backdrop click, the X button) so dismissal is recorded no
* matter how the user leaves it.
*/
export function startHomeTour(onDismiss: () => void): Driver {
let dismissed = false;
const dismissOnce = () => {
if (dismissed) return;
dismissed = true;
onDismiss();
};

const tour = driver({
animate: true,
smoothScroll: true,
allowClose: true,
overlayColor: '#000000',
overlayOpacity: 0.5,
stagePadding: 8,
stageRadius: 10,
showProgress: true,
progressText: '{{current}} of {{total}}',
popoverClass: 'sg-tour-popover',
doneBtnText: 'Got it',
onDestroyed: () => dismissOnce(),
steps: [
{
element: '[data-testid="home-start-actions"]',
popover: {
title: 'Start with a workspace',
description:
'Clone a repo, open a folder, import an existing one — or pitch an idea and let an AI agent scaffold it. Any of these creates your <strong>workspace</strong>.',
side: 'right',
align: 'start',
},
},
{
element: '[data-testid="recent-projects-panel"]',
popover: {
title: 'Come back anytime',
description:
'Every workspace you create or open is listed here, so switching between projects is one click away.',
side: 'left',
align: 'start',
},
},
{
popover: {
title: 'Then: worktrees + agents',
description:
'<p>Open a workspace, and the worktree-first workflow takes over:</p>' +
'<ol class="sg-tour-list">' +
'<li><strong>Create a worktree</strong> for each branch you want to work on — an isolated working copy, nothing to stash or switch away from.</li>' +
'<li><strong>Launch an AI agent</strong> right inside it. Each worktree can run its own agent, in parallel.</li>' +
'</ol>',
},
},
],
});

tour.drive();

// onDestroyed only fires once driver.js's internal per-step bookkeeping
// (__activeStep/__activeElement) has caught up with the current step —
// that bookkeeping finalizes on an animation-frame timer tied to the
// ~400ms step transition, so advancing through steps faster than that
// (e.g. scripted clicks) can leave it stale and silently skip the
// callback even though the popover really did close. Watching for the
// popover leaving the DOM is a reliable fallback that doesn't depend on
// that internal timing.
const observer = new MutationObserver(() => {
if (!document.querySelector('.driver-popover')) {
observer.disconnect();
dismissOnce();
}
});
observer.observe(document.body, { childList: true });

return tour;
}
106 changes: 106 additions & 0 deletions app/src/renderer/onboarding/tour-theme.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/* Onboarding walkthrough — driver.js popover/overlay retheme onto SproutGit's
--sg-* design tokens. Selectors mirror driver.js's own class names
(see driver.js/dist/driver.css) so this file only needs to win on
specificity, not fight !important. */

.sg-tour-popover.driver-popover {
background: var(--sg-surface);
color: var(--sg-text);
border: 1px solid var(--sg-border);
border-radius: 14px;
padding: 18px;
max-width: 320px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
}

.sg-tour-popover .driver-popover-title {
font-family: var(--sg-font-heading);
font-size: 15px;
font-weight: 700;
color: var(--sg-text);
}

.sg-tour-popover .driver-popover-description {
font-family: var(--sg-font-body);
font-size: 12px;
line-height: 1.6;
color: var(--sg-text-dim);
}

.sg-tour-popover .driver-popover-title[style*='block'] + .driver-popover-description {
margin-top: 8px;
}

.sg-tour-popover .driver-popover-description p {
margin: 0 0 8px;
}
.sg-tour-popover .driver-popover-description p:last-child {
margin-bottom: 0;
}

.sg-tour-popover .sg-tour-list {
margin: 0;
padding-left: 18px;
display: flex;
flex-direction: column;
gap: 6px;
}

.sg-tour-popover .driver-popover-description strong {
color: var(--sg-text);
font-weight: 600;
}

.sg-tour-popover .driver-popover-close-btn {
color: var(--sg-text-faint);
}
.sg-tour-popover .driver-popover-close-btn:hover,
.sg-tour-popover .driver-popover-close-btn:focus {
color: var(--sg-text);
}

.sg-tour-popover .driver-popover-footer {
margin-top: 16px;
}

.sg-tour-popover .driver-popover-progress-text {
color: var(--sg-text-faint);
font-size: 11px;
}

.sg-tour-popover .driver-popover-footer-btn {
background: transparent;
border: 1px solid var(--sg-border);
color: var(--sg-text-dim);
border-radius: 6px;
padding: 5px 12px;
font-size: 11px;
font-weight: 500;
transition: background-color 0.15s, color 0.15s, border-color 0.15s;
}
.sg-tour-popover .driver-popover-footer-btn:hover,
.sg-tour-popover .driver-popover-footer-btn:focus {
background: var(--sg-surface-raised);
}
.sg-tour-popover .driver-popover-footer-btn.driver-popover-btn-disabled {
opacity: 0.4;
}

.sg-tour-popover .driver-popover-next-btn,
.sg-tour-popover .driver-popover-done-btn {
background: var(--sg-primary);
border-color: var(--sg-primary);
color: #fff;
}
.sg-tour-popover .driver-popover-next-btn:hover,
.sg-tour-popover .driver-popover-done-btn:hover {
background: var(--sg-primary-hover);
border-color: var(--sg-primary-hover);
}

/* Retint only the arrow's visible edge — the other three are already
transparent via driver.css's own per-side rules. */
.sg-tour-popover .driver-popover-arrow-side-left { border-left-color: var(--sg-surface); }
.sg-tour-popover .driver-popover-arrow-side-right { border-right-color: var(--sg-surface); }
.sg-tour-popover .driver-popover-arrow-side-top { border-top-color: var(--sg-surface); }
.sg-tour-popover .driver-popover-arrow-side-bottom { border-bottom-color: var(--sg-surface); }
61 changes: 57 additions & 4 deletions app/src/renderer/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { api } from '../api.js';
import { createRoute, useNavigate } from '@tanstack/react-router';
import { rootRoute } from './__root.js';
import { useState, useEffect, useRef } from 'react';
import { AppWindow, ArrowRight, Clock, Download, FolderInput, FolderOpen, Play, Settings, Sparkles, X, AlertTriangle } from 'lucide-react';
import { AppWindow, ArrowRight, Clock, Download, FolderInput, FolderOpen, HelpCircle, Play, Settings, Sparkles, X, AlertTriangle } from 'lucide-react';
import { Spinner, WindowControls, UpdateBadge, Autocomplete, ResizableSidebar } from '@sproutgit/ui';
import type { UpdateState } from '@sproutgit/ui';
import type { Driver } from 'driver.js';
import type { AgentConfig, GitHubRepo, GitInfo, GitHubAuthStatus, GitOpProgressEvent, RecentWorkspace } from '@sproutgit/types';
import { useToast } from '../toast-context.js';
import { reportError } from '../error-reporting.js';
import { setPendingScaffold } from '../pending-scaffold.js';
import { startHomeTour, HOME_TOUR_SETTING_KEY } from '../onboarding/homeTour.js';
import logoSvgUrl from '../logo.svg?inline';

// Shared Tailwind class strings
Expand Down Expand Up @@ -76,6 +78,11 @@ function HomeView() {
const [gitNotInstalled, setGitNotInstalled] = useState(false);
const [updateState, setUpdateState] = useState<UpdateState>({ status: 'idle' });

// First-run walkthrough — see onboarding/homeTour.ts
const [recentsLoaded, setRecentsLoaded] = useState(false);
const [tourSeen, setTourSeen] = useState<boolean | null>(null);
const tourRef = useRef<Driver | null>(null);

// GitHub repos for clone autocomplete
const [githubRepos, setGithubRepos] = useState<GitHubRepo[]>([]);

Expand Down Expand Up @@ -133,7 +140,11 @@ function HomeView() {
void api.listRecentWorkspaces().then((ws: RecentWorkspace[]) => {
const sorted = [...ws].sort((a, b) => new Date(b.lastOpenedAt).getTime() - new Date(a.lastOpenedAt).getTime());
setRecents(sorted);
}).catch(() => undefined);
}).catch(() => undefined).finally(() => setRecentsLoaded(true));

void api.getSetting(HOME_TOUR_SETTING_KEY)
.then(v => setTourSeen(!!v))
.catch(() => setTourSeen(true)); // fail closed — don't nag if we can't tell

void api.getSetting(PROJECTS_FOLDER_SETTING).then(async (v: string | null) => {
if (v) {
Expand Down Expand Up @@ -180,6 +191,45 @@ function HomeView() {
return () => { offChecking(); offAvailable(); offNotAvailable(); offDownloading(); offReady(); offError(); };
}, []);

// Auto-launch the first-run walkthrough once we know there's nothing to
// interrupt: git is present, recents have loaded (so we're not showing it
// to a returning user for the split second before their list appears),
// and it hasn't been seen before. Skipped entirely in E2E — WebdriverIO
// reuses one long-lived session across every spec file, and several specs
// click home-screen buttons directly; an unrelated auto-tour stealing that
// first click would make the whole suite's outcome depend on spec order.
// The manual "Replay walkthrough" button still works in E2E for dedicated
// coverage of the tour itself.
useEffect(() => {
if (api.isE2E) return;
if (!gitChecked || gitNotInstalled) return;
if (!recentsLoaded || tourSeen !== false) return;
if (recents.length > 0) return;
tourRef.current?.destroy();
tourRef.current = startHomeTour(() => {
setTourSeen(true);
void api.setSetting(HOME_TOUR_SETTING_KEY, '1').catch(() => undefined);
});
}, [gitChecked, gitNotInstalled, recentsLoaded, tourSeen, recents.length]);

// Unconditional, mount-once cleanup: driver.js appends the popover/overlay
// straight to document.body, outside React's tree — if the user clicks
// through to a workspace without ever dismissing the tour (the highlighted
// step's own action buttons stay clickable), it would otherwise survive
// the route change. Deliberately its own effect, not the guarded one
// above — that effect's cleanup only fires on its OWN re-run, and once
// tourSeen flips true it re-runs into an early return with no cleanup,
// silently dropping the destroy call this needs to guarantee on unmount.
useEffect(() => () => tourRef.current?.destroy(), []);

function replayTour() {
tourRef.current?.destroy();
tourRef.current = startHomeTour(() => {
setTourSeen(true);
void api.setSetting(HOME_TOUR_SETTING_KEY, '1').catch(() => undefined);
});
}

useEffect(() => {
if (!cloneFolderManual) setCloneFolderName(repoNameFromUrl(cloneUrl));
}, [cloneUrl, cloneFolderManual]);
Expand Down Expand Up @@ -436,6 +486,9 @@ function HomeView() {
<div className="flex items-center h-full pr-2 gap-1" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
<UpdateBadge state={updateState} onInstall={() => void api.installUpdate()} />
<button className={iconBtn} title="Replay walkthrough" data-testid="btn-replay-tour" onClick={replayTour}>
<HelpCircle size={15} />
</button>
<button className={iconBtn} title="New Window" onClick={() => void api.openNewWindow()}>
<AppWindow size={15} />
</button>
Expand All @@ -455,7 +508,7 @@ function HomeView() {
<span>Start</span>
</div>

<div className="flex flex-col gap-0.5 p-2">
<div className="flex flex-col gap-0.5 p-2" data-testid="home-start-actions">
{!!agentConfig?.command.trim() && (
<button className={actionBtn} data-testid="btn-new-from-idea" onClick={() => { resetIdeaState(); setShowIdea(true); }}>
<span className={actionIcon}><Sparkles size={14} strokeWidth={2} /></span>
Expand Down Expand Up @@ -492,7 +545,7 @@ function HomeView() {
</ResizableSidebar>

{/* Main */}
<main className="flex-1 min-w-0 flex flex-col bg-(--sg-bg)">
<main className="flex-1 min-w-0 flex flex-col bg-(--sg-bg)" data-testid="recent-projects-panel">
<div className={`${sectionHeader} bg-(--sg-surface)`}>
<Clock size={11} strokeWidth={2.5} style={{ color: 'var(--sg-primary)' }} />
<span>Recent projects</span>
Expand Down
17 changes: 14 additions & 3 deletions app/src/renderer/workspace/WorktreeSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -648,7 +648,7 @@ export function WorktreeSidebar({
aria-label="Worktrees"
>
{inventoryRows.length === 0 && !creatingWorktree && (
<div className="m-3 rounded-lg border border-dashed border-(--sg-border) p-4 flex flex-col items-center gap-3 text-center">
<div className="m-3 rounded-lg border border-dashed border-(--sg-border) p-4 flex flex-col items-center gap-3 text-center" data-testid="worktree-empty-state">
<div className="w-9 h-9 rounded-full bg-(--sg-surface-raised) flex items-center justify-center">
<GitBranch size={18} className="text-(--sg-primary)" />
</div>
Expand All @@ -657,13 +657,24 @@ export function WorktreeSidebar({
No worktrees yet
</p>
<p className="text-[11px] text-(--sg-text-faint) mt-1 m-0 leading-relaxed">
Create a worktree for each branch you want to work on in
parallel.
A worktree is its own isolated working copy of one branch — nothing to stash or switch away from when you juggle several at once.
</p>
</div>
<div className="w-full flex flex-col gap-1.5 text-left">
<div className="flex items-center gap-2 text-[11px] text-(--sg-text-dim)">
<span className="flex items-center justify-center w-4 h-4 rounded-full bg-(--sg-primary)/15 text-(--sg-primary) text-[9px] font-bold shrink-0">1</span>
Create a worktree for the branch you want to work on
</div>
<div className="flex items-center gap-2 text-[11px] text-(--sg-text-dim)">
<span className="flex items-center justify-center w-4 h-4 rounded-full bg-(--sg-primary)/15 text-(--sg-primary) text-[9px] font-bold shrink-0">2</span>
<Bot size={12} className="text-(--sg-text-faint) shrink-0" />
Launch an AI agent right inside it
</div>
</div>
<button
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-(--sg-primary) text-white text-xs font-medium hover:bg-(--sg-primary-hover) transition-colors cursor-pointer border-none"
onClick={onNewWorktree}
data-testid="btn-create-first-worktree"
>
<Plus size={12} /> Create first worktree
</button>
Expand Down
Loading
Loading