From f4fca916c0a8a589321f10d0a5fa24fede6c467c Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Tue, 7 Jul 2026 21:02:53 -0500 Subject: [PATCH] feat(onboarding): close the first-run loop + git-aware project card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the first-time TUI experience via the ty-qa-firstrun harness surfaced three papercuts against the "get an agent working on my code with minimal setup" job-to-be-done: 1. Momentum dead-end: accepting "Create Project" from the detection card (or the Welcome fork's folder picker) dropped the user onto an empty board that just said "press n". Setting up the project is only step one — the job is to run a task in it. Now creation carries straight into the first-task form, pre-selected to the new project (via SetLastUsedProject). esc returns to the board. 2. Wrong noun: the detection card said "Create a TaskYou project for this repo?" and "This directory looks like a project" even for a non-git marker folder (Worktrees: false). Now git-aware: "repo" vs "folder", and the "Worktrees: true/false" jargon is spelled out as what it means — "Isolation: each task runs in its own git worktree" vs "Isolation: off". 3. Unlabeled fork: the Welcome fork's two buttons ("Set up a project" / "Just start a task") didn't say what they do. Added a one-line hint under the highlighted choice. Card copy is extracted into pure, unit-tested helpers (projectDetectTitle / projectDetectDescription). Verified all three scenarios + the momentum jump end-to-end with scripts/qa/ty-qa-firstrun.sh. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/ui/app.go | 32 +++++++----------- internal/ui/project_detect.go | 42 +++++++++++++++++++++++ internal/ui/project_detect_flow_test.go | 15 +++++++++ internal/ui/project_detect_test.go | 44 +++++++++++++++++++++++++ internal/ui/welcome.go | 13 +++++++- internal/ui/welcome_test.go | 25 ++++++++++++++ scripts/qa/ty-qa-firstrun.sh | 6 ++-- 7 files changed, 152 insertions(+), 25 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 21007646..45791156 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -3177,30 +3177,13 @@ func (m *AppModel) buildProjectDetectForm() { return } - var desc strings.Builder - if m.detectedInferencePending { - desc.WriteString("✨ Inferring project details…\n\n") - } - desc.WriteString(fmt.Sprintf("This directory looks like a project.\n\nName: %s\n", project.Name)) - if project.Aliases != "" { - desc.WriteString(fmt.Sprintf("Alias: %s\n", project.Aliases)) - } - desc.WriteString(fmt.Sprintf("Path: %s\n", project.Path)) - if m.detectedInstructionSource != "" { - desc.WriteString(fmt.Sprintf("Instructions: imported from %s\n", m.detectedInstructionSource)) - } else if project.Instructions != "" { - desc.WriteString("Description: " + firstLine(project.Instructions) + "\n") - } - desc.WriteString(fmt.Sprintf("Worktrees: %v\n", project.UseWorktrees)) - desc.WriteString("\nYou can edit any of this later in Settings.") - modalWidth := min(64, m.width-8) m.projectDetectConfirm = huh.NewForm( huh.NewGroup( huh.NewConfirm(). Key("create_project"). - Title("Create a TaskYou project for this repo?"). - Description(desc.String()). + Title(projectDetectTitle(project.UseWorktrees)). + Description(projectDetectDescription(project, m.detectedInstructionSource, m.detectedInferencePending)). Affirmative("Create Project"). Negative("Not Now"). Value(&m.projectDetectConfirmValue), @@ -3328,10 +3311,17 @@ func (m *AppModel) createDetectedProject(project *db.Project) tea.Cmd { // Refresh project color cache so the new project renders consistently. LoadProjectColors(m.db) - m.notification = fmt.Sprintf("%s Created project \"%s\"", IconDone(), project.Name) + m.notification = fmt.Sprintf("%s Created project \"%s\" — describe your first task", IconDone(), project.Name) m.notifyUntil = time.Now().Add(5 * time.Second) - return m.loadTasks() + // Momentum: setting up the project is only step one — the job is to run a + // task in it. Drop straight into the first-task form (pre-selected to this + // project via SetLastUsedProject above) instead of dead-ending on an empty + // board that just says "press n". esc from the form returns to the board. + m.newTaskForm = NewFormModel(m.db, m.width, m.height, project.Path, m.availableExecutors) + m.previousView = ViewDashboard + m.currentView = ViewNewTask + return tea.Batch(m.loadTasks(), m.newTaskForm.Init()) } func (m *AppModel) showQuitConfirm() (tea.Model, tea.Cmd) { diff --git a/internal/ui/project_detect.go b/internal/ui/project_detect.go index 3aebc82a..72426c7a 100644 --- a/internal/ui/project_detect.go +++ b/internal/ui/project_detect.go @@ -1,6 +1,7 @@ package ui import ( + "fmt" "os" "path/filepath" "strconv" @@ -137,6 +138,47 @@ func detectProjectFromDir(dir string) (project *db.Project, instructionSource st }, source } +// projectDetectTitle returns the confirm-modal title, git-aware so we never call +// a non-git folder a "repo". +func projectDetectTitle(useWorktrees bool) string { + if useWorktrees { + return "Create a TaskYou project for this repo?" + } + return "Create a TaskYou project for this folder?" +} + +// projectDetectDescription renders the body of the "New Project Detected" confirm +// card. Kept pure (no huh/AppModel deps) so the first-run copy is unit-testable, +// and phrased for a first-timer: git-aware ("repo" vs "folder") with the worktree +// jargon spelled out as what it actually means for their tasks. +func projectDetectDescription(project *db.Project, instructionSource string, inferencePending bool) string { + var desc strings.Builder + if inferencePending { + desc.WriteString("✨ Inferring project details…\n\n") + } + kind := "folder" + if project.UseWorktrees { + kind = "git repo" + } + desc.WriteString(fmt.Sprintf("This %s looks like a project.\n\nName: %s\n", kind, project.Name)) + if project.Aliases != "" { + desc.WriteString(fmt.Sprintf("Alias: %s\n", project.Aliases)) + } + desc.WriteString(fmt.Sprintf("Path: %s\n", project.Path)) + if instructionSource != "" { + desc.WriteString(fmt.Sprintf("Instructions: imported from %s\n", instructionSource)) + } else if project.Instructions != "" { + desc.WriteString("Description: " + firstLine(project.Instructions) + "\n") + } + if project.UseWorktrees { + desc.WriteString("Isolation: each task runs in its own git worktree\n") + } else { + desc.WriteString("Isolation: off — not a git repo, so tasks run in the folder directly\n") + } + desc.WriteString("\nYou can edit any of this later in Settings.") + return desc.String() +} + // uniqueProjectName ensures the inferred name doesn't collide with an existing // project name (or alias), appending a numeric suffix if needed. func uniqueProjectName(database *db.DB, name string) string { diff --git a/internal/ui/project_detect_flow_test.go b/internal/ui/project_detect_flow_test.go index a5f0198b..3d67661d 100644 --- a/internal/ui/project_detect_flow_test.go +++ b/internal/ui/project_detect_flow_test.go @@ -121,4 +121,19 @@ func TestCreateDetectedProjectPersists(t *testing.T) { if m.notification == "" { t.Error("expected a success notification") } + + // Momentum: creating the project should carry the user straight into the + // first-task form (pre-selected to the new project), not a bare board. + if m.currentView != ViewNewTask { + t.Fatalf("expected ViewNewTask after creating project, got %v", m.currentView) + } + if m.newTaskForm == nil { + t.Fatal("expected a new-task form to be opened after project creation") + } + if m.newTaskForm.project != "myrepo" { + t.Errorf("expected first-task form pre-selected to myrepo, got %q", m.newTaskForm.project) + } + if m.previousView != ViewDashboard { + t.Errorf("expected esc from the form to return to the board, got previousView %v", m.previousView) + } } diff --git a/internal/ui/project_detect_test.go b/internal/ui/project_detect_test.go index 21e859aa..6c25b535 100644 --- a/internal/ui/project_detect_test.go +++ b/internal/ui/project_detect_test.go @@ -17,6 +17,50 @@ func mkGitRepo(t *testing.T, dir string) { } } +func TestProjectDetectTitle(t *testing.T) { + if got := projectDetectTitle(true); !strings.Contains(got, "repo") { + t.Errorf("git project title should say repo, got %q", got) + } + // A non-git folder must not be called a "repo". + got := projectDetectTitle(false) + if strings.Contains(got, "repo") { + t.Errorf("non-git title should not say repo, got %q", got) + } + if !strings.Contains(got, "folder") { + t.Errorf("non-git title should say folder, got %q", got) + } +} + +func TestProjectDetectDescription(t *testing.T) { + git := projectDetectDescription(&db.Project{Name: "acme", Path: "/x", UseWorktrees: true}, "README.md", false) + if !strings.Contains(git, "This git repo looks like a project") { + t.Errorf("git description wording: %q", git) + } + if !strings.Contains(git, "imported from README.md") { + t.Errorf("git description should note imported instructions: %q", git) + } + if !strings.Contains(git, "own git worktree") { + t.Errorf("git description should explain worktree isolation: %q", git) + } + + nonGit := projectDetectDescription(&db.Project{Name: "acme", Path: "/x", UseWorktrees: false}, "", false) + if strings.Contains(nonGit, "git repo looks like") { + t.Errorf("non-git description should not call the folder a git repo: %q", nonGit) + } + if !strings.Contains(nonGit, "This folder looks like a project") { + t.Errorf("non-git description wording: %q", nonGit) + } + if !strings.Contains(nonGit, "Isolation: off") { + t.Errorf("non-git description should explain isolation is off: %q", nonGit) + } + + // Pending inference prepends the spinner beat. + pending := projectDetectDescription(&db.Project{Name: "acme", Path: "/x", UseWorktrees: true}, "", true) + if !strings.Contains(pending, "Inferring project details") { + t.Errorf("pending description should show inference beat: %q", pending) + } +} + func TestDirIsGitRepo(t *testing.T) { tmp := t.TempDir() diff --git a/internal/ui/welcome.go b/internal/ui/welcome.go index 0c0a8ce1..9a069be0 100644 --- a/internal/ui/welcome.go +++ b/internal/ui/welcome.go @@ -62,6 +62,16 @@ func missingPrereqNotices(tmuxFound bool, agents []string) []string { return notices } +// welcomeChoiceHint returns a one-line description of the currently highlighted +// choice so a first-timer knows what each button does before pressing enter +// (the labels alone don't say "picks a folder" vs "no setup needed"). +func welcomeChoiceHint(cursor int) string { + if cursor == 0 { + return "Point TaskYou at a folder — tasks run against that codebase" + } + return "Start a task now in your personal space — no project setup" +} + // MoveLeft/MoveRight/Choice drive selection; key handling lives in app.go so it // composes with the global update loop (mirrors viewProjectDetectConfirm). func (m *WelcomeModel) MoveLeft() { m.cursor = 0 } @@ -95,7 +105,8 @@ func (m *WelcomeModel) View() string { HelpKey.Render("←/→") + " " + HelpDesc.Render("choose") + " " + HelpKey.Render("enter") + " " + HelpDesc.Render("select")) - parts := []string{title, "", body, "", buttons} + hint := lipgloss.NewStyle().Foreground(ColorMuted).Italic(true).Render(welcomeChoiceHint(m.cursor)) + parts := []string{title, "", body, "", buttons, "", hint} if agents := formatDetectedAgents(m.detectedAgents); agents != "" { parts = append(parts, "", Success.Render(agents)) } diff --git a/internal/ui/welcome_test.go b/internal/ui/welcome_test.go index a18680ec..b88d797e 100644 --- a/internal/ui/welcome_test.go +++ b/internal/ui/welcome_test.go @@ -45,6 +45,31 @@ func TestMissingPrereqNotices(t *testing.T) { } } +func TestWelcomeChoiceHint(t *testing.T) { + setup := welcomeChoiceHint(0) + task := welcomeChoiceHint(1) + if setup == task { + t.Fatal("each choice should have a distinct hint") + } + if !strings.Contains(setup, "folder") { + t.Errorf("set-up-project hint should mention pointing at a folder, got %q", setup) + } + if !strings.Contains(task, "personal") { + t.Errorf("start-task hint should mention the personal space, got %q", task) + } +} + +func TestWelcomeViewShowsChoiceHint(t *testing.T) { + m := NewWelcomeModel(100, 40, []string{"claude"}, true) + if !strings.Contains(m.View(), welcomeChoiceHint(0)) { + t.Error("welcome view should show the hint for the highlighted choice") + } + m.MoveRight() + if !strings.Contains(m.View(), welcomeChoiceHint(1)) { + t.Error("welcome view should update the hint when the highlight moves") + } +} + func TestWelcomeViewShowsEnvironmentStatus(t *testing.T) { // Agents detected, tmux missing: confidence beat + visible, non-blocking notice. m := NewWelcomeModel(100, 40, []string{"claude", "codex"}, false) diff --git a/scripts/qa/ty-qa-firstrun.sh b/scripts/qa/ty-qa-firstrun.sh index 27c4ca0d..0d1cd28e 100755 --- a/scripts/qa/ty-qa-firstrun.sh +++ b/scripts/qa/ty-qa-firstrun.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Drive the FIRST-RUN onboarding experience across folder types against an # isolated ty instance. Exercises the launch decision tree: -# - project candidate (git repo) -> enriched "New Project Detected" card -# - project candidate (non-git marker)-> card with Worktrees: false (git optional) +# - project candidate (git repo) -> enriched "…for this repo?" card, isolation on +# - project candidate (non-git marker)-> "…for this folder?" card, isolation off (git optional) # - junk folder (no signals) -> Welcome fork (set up a project / start a task) # # Each scenario uses a FRESH isolated DB so it's a true first run. The suggestion @@ -47,7 +47,7 @@ cap() { tmux capture-pane -t "${SID}:tui" -p | sed 's/[[:space:]]*$//' | grep -v echo; echo "### Scenario A: git repo -> enriched suggestion card (inference, ~15s)" launch "$GITPROJ"; sleep 16; cap | head -22 -echo; echo "### Scenario B: non-git marker folder -> card with Worktrees: false (~15s)" +echo; echo "### Scenario B: non-git marker folder -> \"…for this folder?\" card, isolation off (~15s)" launch "$MARKER"; sleep 16; cap | head -22 echo; echo "### Scenario C: plain folder -> Welcome fork"