Skip to content

Commit 7fcd508

Browse files
frenchie4111claude
andauthored
Onboarding: starter prompts, first-agent nudge, parallelism quest (#10)
* Add starter task prompts to the new-worktree screen Replaces the locked "Pull from backlog" placeholder with three one-click starter prompts (Map the repo / Find TODOs / Sharpen README) that fill the branch name and kickoff textarea. Removes the blank-prompt paralysis that stalls first-run sessions and doubles as a useful shortcut for returning users. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add first-agent nudge and parallelism quest Two onboarding nudges aimed at the landing page's promise of running many agents at once: - Sidebar CTA shown while the user has zero non-main worktrees. A small gradient-accented card above the grouped list: "Spawn your first agent." Clicking it opens the new-worktree screen. Disappears the moment an agent worktree exists. - Parallelism quest — a floating card bottom-right that walks a new user through the one non-obvious lesson: don't sit and watch one agent, fork a second while the first is still running, then jump between them with ⌘1/⌘2. Three steps (spawn-second, switch-between, finale) driven reactively by worktree count and activeWorktreeId transitions, persisted across restarts via a new config.onboarding field and config:getOnboarding / config:setOnboardingQuest IPC. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4b6efc3 commit 7fcd508

9 files changed

Lines changed: 261 additions & 22 deletions

File tree

src/main/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ import {
2222
loadTerminalHistory,
2323
clearTerminalHistory,
2424
pruneTerminalHistory,
25-
type PersistedTab
25+
type PersistedTab,
26+
type QuestStep
2627
} from './persistence'
2728
import { hooksInstalled, installHooks, watchStatusDir } from './hooks'
2829
import { log, getLogFilePath } from './debug'
@@ -416,6 +417,18 @@ function registerIpcHandlers(): void {
416417
return AVAILABLE_THEMES
417418
})
418419

420+
ipcMain.handle('config:getOnboarding', () => {
421+
return config.onboarding || { quest: 'hidden' }
422+
})
423+
424+
ipcMain.handle('config:setOnboardingQuest', (_, quest: string) => {
425+
const valid = ['hidden', 'spawn-second', 'switch-between', 'finale', 'done']
426+
if (!valid.includes(quest)) return false
427+
config.onboarding = { ...(config.onboarding || {}), quest: quest as QuestStep }
428+
saveConfig(config)
429+
return true
430+
})
431+
419432
// Persisted terminal tabs / active tab ids
420433
ipcMain.handle('config:getTerminalTabs', () => {
421434
return {

src/main/persistence.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export interface PersistedTab {
1010
sessionId?: string
1111
}
1212

13+
export type QuestStep = 'hidden' | 'spawn-second' | 'switch-between' | 'finale' | 'done'
14+
1315
interface Config {
1416
windowBounds: { x: number; y: number; width: number; height: number } | null
1517
// All repo roots that have been opened (for re-opening windows)
@@ -38,6 +40,10 @@ interface Config {
3840
// past this SHA, the flag is considered stale and the branch is no longer
3941
// shown as merged.
4042
locallyMerged?: Record<string, string>
43+
// First-run parallelism quest — advances through steps as the user learns.
44+
onboarding?: {
45+
quest?: QuestStep
46+
}
4147
}
4248

4349
export const DEFAULT_WORKTREE_BASE: 'remote' | 'local' = 'remote'

src/preload/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ contextBridge.exposeInMainWorld('api', {
7676
return () => ipcRenderer.removeListener('config:claudeCommandChanged', handler)
7777
},
7878

79+
// Onboarding quest
80+
getOnboarding: () => ipcRenderer.invoke('config:getOnboarding'),
81+
setOnboardingQuest: (quest: string) => ipcRenderer.invoke('config:setOnboardingQuest', quest),
82+
7983
// Worktree base
8084
getWorktreeBase: () => ipcRenderer.invoke('config:getWorktreeBase'),
8185
setWorktreeBase: (mode: 'remote' | 'local') =>

src/renderer/App.tsx

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
2-
import type { Worktree, TerminalTab, PtyStatus, PRStatus } from './types'
2+
import type { Worktree, TerminalTab, PtyStatus, PRStatus, QuestStep } from './types'
33
import type { Action } from './hotkeys'
44
import { resolveHotkeys } from './hotkeys'
55
import { HotkeysProvider } from './components/Tooltip'
66
import { Sidebar } from './components/Sidebar'
77
import { NewWorktreeScreen } from './components/NewWorktreeScreen'
8+
import { QuestCard } from './components/QuestCard'
89
import { TerminalPanel } from './components/TerminalPanel'
910
import { ChangedFilesPanel } from './components/ChangedFilesPanel'
1011
import { PRStatusPanel } from './components/PRStatusPanel'
@@ -44,6 +45,17 @@ export default function App(): JSX.Element {
4445
)
4546
const [hotkeyOverrides, setHotkeyOverrides] = useState<Record<string, string> | undefined>(undefined)
4647
const [claudeCommand, setClaudeCommand] = useState<string>('')
48+
// Onboarding parallelism quest — see QuestCard.tsx for the steps.
49+
const [questStep, setQuestStepRaw] = useState<QuestStep>('hidden')
50+
const questLoadedRef = useRef(false)
51+
const questVisitedRef = useRef<Set<string>>(new Set())
52+
53+
// Count of "real" worktrees the user has spawned — main repo doesn't count
54+
// as an agent. Used by empty state + quest advancement.
55+
const agentWorktreeCount = useMemo(
56+
() => worktrees.filter((w) => !w.isMain).length,
57+
[worktrees]
58+
)
4759
// Flipped to true after persisted tabs finish loading, so the persist effect
4860
// below doesn't overwrite the on-disk state with empty initial React state.
4961
const tabsLoadedRef = useRef(false)
@@ -64,6 +76,46 @@ export default function App(): JSX.Element {
6476
setGithubBannerDismissed(true)
6577
}, [])
6678

79+
const setQuestStep = useCallback((next: QuestStep) => {
80+
setQuestStepRaw(next)
81+
window.api.setOnboardingQuest(next).catch(() => {})
82+
}, [])
83+
84+
// Load persisted quest state on mount
85+
useEffect(() => {
86+
window.api
87+
.getOnboarding()
88+
.then((o) => {
89+
setQuestStepRaw(o?.quest ?? 'hidden')
90+
})
91+
.catch(() => {})
92+
.finally(() => {
93+
questLoadedRef.current = true
94+
})
95+
}, [])
96+
97+
// Advance the quest based on how many agent worktrees exist (main excluded)
98+
useEffect(() => {
99+
if (!questLoadedRef.current) return
100+
if (questStep === 'done' || questStep === 'finale') return
101+
if (questStep === 'hidden' && agentWorktreeCount >= 1) {
102+
setQuestStep(agentWorktreeCount >= 2 ? 'switch-between' : 'spawn-second')
103+
return
104+
}
105+
if (questStep === 'spawn-second' && agentWorktreeCount >= 2) {
106+
setQuestStep('switch-between')
107+
}
108+
}, [agentWorktreeCount, questStep, setQuestStep])
109+
110+
// Advance the quest when the user switches between two different worktrees
111+
useEffect(() => {
112+
if (questStep !== 'switch-between' || !activeWorktreeId) return
113+
questVisitedRef.current.add(activeWorktreeId)
114+
if (questVisitedRef.current.size >= 2) {
115+
setQuestStep('finale')
116+
}
117+
}, [activeWorktreeId, questStep, setQuestStep])
118+
67119
// Load repo root, worktrees, and config on mount
68120
useEffect(() => {
69121
(async () => {
@@ -771,6 +823,7 @@ export default function App(): JSX.Element {
771823
mergedPaths={mergedPaths}
772824
lastActive={lastActive}
773825
prLoading={prLoading}
826+
agentCount={agentWorktreeCount}
774827
onSelectWorktree={(path) => {
775828
setShowNewWorktree(false)
776829
setActiveWorktreeId(path)
@@ -821,6 +874,11 @@ export default function App(): JSX.Element {
821874
Select a worktree to begin
822875
</div>
823876
)}
877+
<QuestCard
878+
step={questStep}
879+
onDismiss={() => setQuestStep('done')}
880+
onFinish={() => setQuestStep('done')}
881+
/>
824882
{/* Right panel — hidden on the new-worktree screen so the form gets the full width */}
825883
{!showNewWorktree && (
826884
<div className="w-64 shrink-0 h-full flex flex-col border-l border-border bg-panel">

src/renderer/components/NewWorktreeScreen.tsx

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useCallback, useEffect, useRef } from 'react'
2-
import { Sparkles, Loader2, X, Lock, ListTodo, CircleDot, Zap } from 'lucide-react'
2+
import { Sparkles, Loader2, X, Map, ListChecks, BookOpen } from 'lucide-react'
33
import iconUrl from '../../../resources/icon.png'
44
import { sanitizeBranchInput, isValidBranchName } from '../branch-name'
55

@@ -8,6 +8,33 @@ interface NewWorktreeScreenProps {
88
onCancel: () => void
99
}
1010

11+
const STARTER_PROMPTS = [
12+
{
13+
icon: Map,
14+
label: 'Map the repo',
15+
hint: 'A one-paragraph architecture summary',
16+
branch: 'map-repo',
17+
prompt:
18+
"Read this repo and write a one-paragraph summary of its architecture in SCRATCH.md — what the major pieces are and how they fit together. Keep it under 150 words."
19+
},
20+
{
21+
icon: ListChecks,
22+
label: 'Find the TODOs',
23+
hint: 'Collect every TODO/FIXME in one file',
24+
branch: 'find-todos',
25+
prompt:
26+
"Search the codebase for every TODO and FIXME comment. Write TODOS.md grouping them by file with line numbers and the comment text. Don't fix anything — just catalog."
27+
},
28+
{
29+
icon: BookOpen,
30+
label: 'Sharpen the README',
31+
hint: 'Get 3 concrete improvement ideas',
32+
branch: 'sharpen-readme',
33+
prompt:
34+
"Read the README and propose 3 specific, concrete improvements (not vague advice). Reply with the suggestions — don't make any changes yet so I can review first."
35+
}
36+
]
37+
1138
export function NewWorktreeScreen({ onSubmit, onCancel }: NewWorktreeScreenProps): JSX.Element {
1239
const [branch, setBranch] = useState('')
1340
const [prompt, setPrompt] = useState('')
@@ -171,28 +198,31 @@ export function NewWorktreeScreen({ onSubmit, onCancel }: NewWorktreeScreenProps
171198
</div>
172199

173200
<div className="mt-10">
174-
<div className="flex items-baseline gap-2 mb-3 px-1">
201+
<div className="mb-3 px-1">
175202
<span className="text-[11px] font-semibold uppercase tracking-wider text-dim">
176-
Pull from your backlog
203+
Or try a starter task
177204
</span>
178-
<span className="text-[10px] text-faint">coming soon</span>
179205
</div>
180206
<div className="grid sm:grid-cols-3 gap-3">
181-
{[
182-
{ icon: ListTodo, label: 'Todo list', hint: 'Pop the top item off the stack' },
183-
{ icon: CircleDot, label: 'GitHub Issues', hint: 'Pick a labeled issue to tackle' },
184-
{ icon: Zap, label: 'Linear', hint: 'Start on an assigned ticket' }
185-
].map(({ icon: Icon, label, hint }) => (
186-
<div
187-
key={label}
188-
className="relative bg-panel/40 border border-border/60 rounded-xl p-4 opacity-60"
189-
>
190-
<Lock size={10} className="absolute top-2 right-2 text-faint" />
191-
<Icon size={18} className="text-muted mb-2" />
192-
<div className="text-sm text-fg font-medium">{label}</div>
193-
<div className="text-xs text-dim mt-0.5">{hint}</div>
194-
</div>
195-
))}
207+
{STARTER_PROMPTS.map(
208+
({ icon: Icon, label, hint, branch: starterBranch, prompt: starterPrompt }) => (
209+
<button
210+
key={label}
211+
type="button"
212+
onClick={() => {
213+
if (!branch) setBranch(starterBranch)
214+
setPrompt(starterPrompt)
215+
promptRef.current?.focus()
216+
}}
217+
disabled={submitting}
218+
className="text-left bg-panel/60 border border-border/60 hover:border-accent hover:bg-panel rounded-xl p-4 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
219+
>
220+
<Icon size={18} className="text-accent mb-2" />
221+
<div className="text-sm text-fg font-medium">{label}</div>
222+
<div className="text-xs text-dim mt-0.5">{hint}</div>
223+
</button>
224+
)
225+
)}
196226
</div>
197227
</div>
198228
</div>
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { X, Sparkles, Zap, PartyPopper } from 'lucide-react'
2+
import type { QuestStep } from '../types'
3+
4+
interface QuestCardProps {
5+
step: QuestStep
6+
onDismiss: () => void
7+
onFinish: () => void
8+
}
9+
10+
export function QuestCard({ step, onDismiss, onFinish }: QuestCardProps): JSX.Element | null {
11+
if (step === 'hidden' || step === 'done') return null
12+
13+
const content = (() => {
14+
if (step === 'spawn-second') {
15+
return {
16+
icon: <Zap size={16} className="text-accent" />,
17+
eyebrow: 'Step 1 of 2',
18+
title: 'One agent running. Don’t wait — spawn another.',
19+
body: 'The whole point of Harness is that you don’t have to sit and watch. While this one works, fork a second worktree and give it a different task.',
20+
hint: (
21+
<>
22+
Hit <kbd className="font-mono text-fg-bright">⌘N</kbd> or use the sidebar to create another.
23+
</>
24+
)
25+
}
26+
}
27+
if (step === 'switch-between') {
28+
return {
29+
icon: <Sparkles size={16} className="text-accent" />,
30+
eyebrow: 'Step 2 of 2',
31+
title: 'Two agents in flight. Now learn to juggle.',
32+
body: 'Jump between running agents with number hotkeys so you can keep an eye on both without losing your place.',
33+
hint: (
34+
<>
35+
<kbd className="font-mono text-fg-bright">⌘1</kbd> for the first,{' '}
36+
<kbd className="font-mono text-fg-bright">⌘2</kbd> for the second. Try both.
37+
</>
38+
)
39+
}
40+
}
41+
// finale
42+
return {
43+
icon: <PartyPopper size={16} className="text-accent" />,
44+
eyebrow: 'Harnessed up',
45+
title: 'You just ran two Claudes in parallel.',
46+
body: 'Do it with ten next time. Harness is happiest when it has a lot to juggle — the status dots keep you honest so nothing slips.',
47+
hint: null
48+
}
49+
})()
50+
51+
return (
52+
<div className="fixed bottom-4 right-4 z-40 w-80 max-w-[calc(100vw-2rem)] pointer-events-none">
53+
<div className="quest-card-enter pointer-events-auto relative bg-panel-raised border border-border-strong rounded-2xl shadow-2xl overflow-hidden">
54+
<div className="brand-gradient-bg h-1" />
55+
<div className="p-5">
56+
<div className="flex items-start justify-between mb-2">
57+
<div className="flex items-center gap-2">
58+
{content.icon}
59+
<span className="text-[10px] font-semibold uppercase tracking-wider text-dim">
60+
{content.eyebrow}
61+
</span>
62+
</div>
63+
<button
64+
onClick={onDismiss}
65+
title="Dismiss quest"
66+
className="text-faint hover:text-fg p-0.5 rounded transition-colors cursor-pointer -mt-1 -mr-1"
67+
>
68+
<X size={14} />
69+
</button>
70+
</div>
71+
<div className="text-[15px] font-semibold text-fg-bright leading-snug mb-2">
72+
{content.title}
73+
</div>
74+
<div className="text-xs text-muted leading-relaxed">{content.body}</div>
75+
{content.hint && (
76+
<div className="mt-3 text-xs text-dim">{content.hint}</div>
77+
)}
78+
{step === 'finale' && (
79+
<button
80+
onClick={onFinish}
81+
className="mt-4 w-full brand-gradient-bg text-white font-semibold text-sm px-4 py-2 rounded-lg hover:brightness-110 transition-all cursor-pointer"
82+
>
83+
Let’s go ⚡
84+
</button>
85+
)}
86+
</div>
87+
</div>
88+
</div>
89+
)
90+
}

src/renderer/components/Sidebar.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useCallback, useMemo } from 'react'
2-
import { ChevronDown, ChevronRight, Plus, RefreshCw, FolderOpen, Loader2, Settings as SettingsIcon } from 'lucide-react'
2+
import { ChevronDown, ChevronRight, Plus, RefreshCw, FolderOpen, Loader2, Settings as SettingsIcon, Sparkles } from 'lucide-react'
33
import { Tooltip } from './Tooltip'
44
import type { Worktree, PtyStatus, PRStatus } from '../types'
55
import type { GroupKey } from '../worktree-sort'
@@ -14,6 +14,8 @@ interface SidebarProps {
1414
mergedPaths?: Record<string, boolean>
1515
lastActive: Record<string, number>
1616
prLoading: boolean
17+
/** Non-main worktrees. Used to decide whether to show the "spawn your first agent" nudge. */
18+
agentCount: number
1719
onSelectWorktree: (path: string) => void
1820
onNewWorktree: () => void
1921
onContinueWorktree: (worktreePath: string, newBranchName: string) => Promise<void>
@@ -31,6 +33,7 @@ export function Sidebar({
3133
mergedPaths,
3234
lastActive,
3335
prLoading,
36+
agentCount,
3437
onSelectWorktree,
3538
onNewWorktree,
3639
onContinueWorktree,
@@ -118,6 +121,28 @@ export function Sidebar({
118121

119122
{/* Worktree list grouped by PR status */}
120123
<div className="flex-1 overflow-y-auto py-1">
124+
{agentCount === 0 && (
125+
<button
126+
onClick={onNewWorktree}
127+
className="group relative mx-2 mb-2 mt-1 w-[calc(100%-1rem)] text-left bg-panel-raised border border-border-strong hover:border-accent rounded-lg overflow-hidden transition-colors cursor-pointer"
128+
>
129+
<div className="brand-gradient-bg h-0.5" />
130+
<div className="p-3">
131+
<div className="flex items-center gap-1.5 mb-1">
132+
<Sparkles size={11} className="text-accent" />
133+
<span className="text-[10px] font-semibold uppercase tracking-wider text-accent">
134+
Get started
135+
</span>
136+
</div>
137+
<div className="text-[13px] font-semibold text-fg-bright leading-snug">
138+
Spawn your first agent
139+
</div>
140+
<div className="text-[11px] text-dim mt-0.5 leading-snug">
141+
Fork a branch and send a Claude into it.
142+
</div>
143+
</div>
144+
</button>
145+
)}
121146
{groups.map((group) => (
122147
<div key={group.key}>
123148
<button

0 commit comments

Comments
 (0)