Skip to content
Open
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
32 changes: 11 additions & 21 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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) {
Expand Down
42 changes: 42 additions & 0 deletions internal/ui/project_detect.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ui

import (
"fmt"
"os"
"path/filepath"
"strconv"
Expand Down Expand Up @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions internal/ui/project_detect_flow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
44 changes: 44 additions & 0 deletions internal/ui/project_detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
13 changes: 12 additions & 1 deletion internal/ui/welcome.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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))
}
Expand Down
25 changes: 25 additions & 0 deletions internal/ui/welcome_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions scripts/qa/ty-qa-firstrun.sh
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"
Expand Down