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"