mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 15:05:40 +00:00
Add workspace onboarding: CLI hints, web checklist, codebase detection, and relation field fix
- Print suggested /pad prompts after `pad init` creates a new workspace - Add `pad onboard` command that detects project tooling (language, build system, test runner, CI, linter) and suggests matching conventions from the library - Replace empty workspace welcome box with OnboardingChecklist component showing a 4-step guided setup with progress bar and /pad prompt hints - Add contextual tips with /pad prompts to empty collection states - Add onboarding workflow to /pad skill for agent-driven codebase analysis - Fix relation fields storing slugs instead of UUIDs: server now resolves slugs/refs to UUIDs for relation-type fields on both create and update
This commit is contained in:
+152
@@ -45,6 +45,7 @@ func main() {
|
||||
serveCmd(),
|
||||
stopCmd(),
|
||||
initCmd(),
|
||||
onboardCmd(),
|
||||
workspacesCmd(),
|
||||
switchCmd(),
|
||||
skillsCmd(),
|
||||
@@ -285,6 +286,7 @@ Use --list-templates to see available templates.`,
|
||||
}
|
||||
}
|
||||
|
||||
newlyCreated := false
|
||||
if ws != nil {
|
||||
if err := cli.WriteWorkspaceLink(cwd, ws.Slug); err != nil {
|
||||
return fmt.Errorf("write .pad.toml: %w", err)
|
||||
@@ -309,9 +311,14 @@ Use --list-templates to see available templates.`,
|
||||
}
|
||||
fmt.Printf("Created workspace %q (slug: %s)%s\n", ws.Name, ws.Slug, tmplMsg)
|
||||
fmt.Printf("Linked to %s\n", cwd)
|
||||
newlyCreated = true
|
||||
}
|
||||
|
||||
offerSkillInstall()
|
||||
|
||||
if newlyCreated {
|
||||
printOnboardingHints()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -386,6 +393,151 @@ func readChoice() string {
|
||||
return strings.TrimSpace(input)
|
||||
}
|
||||
|
||||
func printOnboardingHints() {
|
||||
fmt.Println()
|
||||
fmt.Println("Get started:")
|
||||
fmt.Println(" /pad scan this codebase and set up my workspace")
|
||||
fmt.Println(" /pad what conventions should this project follow?")
|
||||
fmt.Println(" /pad create a phase for what I'm working on")
|
||||
fmt.Println()
|
||||
fmt.Println("Or open the web UI at http://localhost:7777")
|
||||
}
|
||||
|
||||
// --- onboard ---
|
||||
|
||||
func onboardCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "onboard",
|
||||
Short: "Analyze the project and suggest items to populate the workspace",
|
||||
Long: `Analyze the current project directory to detect tooling and suggest
|
||||
conventions, then optionally create them in the workspace.
|
||||
|
||||
This scans for build config, CI setup, linters, and project structure to
|
||||
recommend conventions from the built-in library.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, _ := getClient()
|
||||
ws := getWorkspace()
|
||||
|
||||
cwd, _ := os.Getwd()
|
||||
info := cli.DetectProject(cwd)
|
||||
|
||||
// Print detection results
|
||||
fmt.Println("Scanning project...")
|
||||
if info.Language != "" {
|
||||
fmt.Printf(" Language: %s\n", info.Language)
|
||||
}
|
||||
if info.BuildTool != "" {
|
||||
fmt.Printf(" Build: %s\n", info.BuildTool)
|
||||
}
|
||||
if info.TestCmd != "" {
|
||||
fmt.Printf(" Tests: %s\n", info.TestCmd)
|
||||
}
|
||||
if info.HasCI {
|
||||
fmt.Printf(" CI: %s\n", info.CIProvider)
|
||||
}
|
||||
if info.HasLinter {
|
||||
fmt.Println(" Linter: detected")
|
||||
}
|
||||
if info.Language == "" && info.BuildTool == "" {
|
||||
fmt.Println(" Could not detect project type.")
|
||||
fmt.Println()
|
||||
fmt.Println("Try using /pad to set up your workspace conversationally:")
|
||||
fmt.Println(" /pad scan this codebase and set up my workspace")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
// Get suggested conventions
|
||||
suggestions := cli.SuggestedConventions(info)
|
||||
|
||||
// Check which are already active
|
||||
existingConventions, _ := client.ListCollectionItems(ws, "conventions", nil)
|
||||
existingTitles := make(map[string]bool)
|
||||
for _, item := range existingConventions {
|
||||
existingTitles[item.Title] = true
|
||||
}
|
||||
|
||||
// Filter to new suggestions only
|
||||
type suggestion struct {
|
||||
title string
|
||||
content string
|
||||
}
|
||||
var newSuggestions []suggestion
|
||||
for title, content := range suggestions {
|
||||
if !existingTitles[title] {
|
||||
newSuggestions = append(newSuggestions, suggestion{title, content})
|
||||
}
|
||||
}
|
||||
|
||||
if len(newSuggestions) == 0 {
|
||||
fmt.Println("All suggested conventions are already active.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Suggested conventions (%d new):\n", len(newSuggestions))
|
||||
for i, s := range newSuggestions {
|
||||
fmt.Printf(" %d. %s\n", i+1, s.title)
|
||||
}
|
||||
|
||||
if !cli.IsTerminal() {
|
||||
// Non-interactive: just print suggestions
|
||||
fmt.Println()
|
||||
fmt.Println("Run 'pad onboard' in a terminal to activate, or use:")
|
||||
fmt.Println(" /pad what conventions should this project follow?")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Print("\nCreate these conventions? (y/N): ")
|
||||
choice := readChoice()
|
||||
if choice != "y" && choice != "Y" {
|
||||
fmt.Println("Skipped. You can activate conventions from the library:")
|
||||
fmt.Printf(" http://localhost:7777/%s/library\n", ws)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Look up library conventions to get proper trigger/scope/priority
|
||||
libraryConventions := collections.ConventionLibrary()
|
||||
libraryMap := make(map[string]collections.LibraryConvention)
|
||||
for _, cat := range libraryConventions {
|
||||
for _, conv := range cat.Conventions {
|
||||
libraryMap[conv.Title] = conv
|
||||
}
|
||||
}
|
||||
|
||||
created := 0
|
||||
for _, s := range newSuggestions {
|
||||
// Use library metadata if available, otherwise use sensible defaults
|
||||
trigger := "on-implement"
|
||||
scope := "all"
|
||||
priority := "should"
|
||||
if lc, ok := libraryMap[s.title]; ok {
|
||||
trigger = lc.Trigger
|
||||
scope = lc.Scope
|
||||
priority = lc.Priority
|
||||
}
|
||||
|
||||
fieldsJSON := fmt.Sprintf(`{"status":"active","trigger":"%s","scope":"%s","priority":"%s"}`, trigger, scope, priority)
|
||||
_, err := client.CreateItem(ws, "conventions", models.ItemCreate{
|
||||
Title: s.title,
|
||||
Content: s.content,
|
||||
Fields: fieldsJSON,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, " Failed to create %q: %v\n", s.title, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" Created: %s\n", s.title)
|
||||
created++
|
||||
}
|
||||
|
||||
fmt.Printf("\n%d conventions created.\n", created)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// --- skills ---
|
||||
|
||||
func skillsCmd() *cobra.Command {
|
||||
|
||||
@@ -8,4 +8,4 @@ var WebUI embed.FS
|
||||
//go:embed skills/pad/SKILL.md
|
||||
var PadSkill []byte
|
||||
|
||||
// embed cache bust: 1774577313
|
||||
// embed cache bust: 1774639009
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProjectInfo holds detected project metadata.
|
||||
type ProjectInfo struct {
|
||||
Language string // go, node, rust, python, java, etc.
|
||||
BuildTool string // make, npm, cargo, pip, maven, etc.
|
||||
TestCmd string // detected test command
|
||||
HasCI bool // CI config detected
|
||||
CIProvider string // github-actions, gitlab, circleci, etc.
|
||||
HasLinter bool // linter config detected
|
||||
Frameworks []string // detected frameworks (e.g., svelte, react, chi)
|
||||
}
|
||||
|
||||
// DetectProject analyzes the current directory to determine project type,
|
||||
// build system, test runner, CI, and other metadata.
|
||||
func DetectProject(dir string) ProjectInfo {
|
||||
info := ProjectInfo{}
|
||||
|
||||
// Detect language and build tool
|
||||
if fileExists(dir, "go.mod") {
|
||||
info.Language = "go"
|
||||
if fileExists(dir, "Makefile") {
|
||||
info.BuildTool = "make"
|
||||
info.TestCmd = detectMakeTestCmd(dir)
|
||||
} else {
|
||||
info.BuildTool = "go"
|
||||
info.TestCmd = "go test ./..."
|
||||
}
|
||||
} else if fileExists(dir, "package.json") {
|
||||
info.Language = "node"
|
||||
info.BuildTool = "npm"
|
||||
info.TestCmd = "npm test"
|
||||
if fileExists(dir, "yarn.lock") {
|
||||
info.BuildTool = "yarn"
|
||||
info.TestCmd = "yarn test"
|
||||
} else if fileExists(dir, "pnpm-lock.yaml") {
|
||||
info.BuildTool = "pnpm"
|
||||
info.TestCmd = "pnpm test"
|
||||
}
|
||||
// Check for TypeScript
|
||||
if fileExists(dir, "tsconfig.json") {
|
||||
info.Language = "typescript"
|
||||
}
|
||||
} else if fileExists(dir, "Cargo.toml") {
|
||||
info.Language = "rust"
|
||||
info.BuildTool = "cargo"
|
||||
info.TestCmd = "cargo test"
|
||||
} else if fileExists(dir, "pyproject.toml") || fileExists(dir, "setup.py") || fileExists(dir, "requirements.txt") {
|
||||
info.Language = "python"
|
||||
if fileExists(dir, "pyproject.toml") {
|
||||
info.BuildTool = "pip"
|
||||
info.TestCmd = "pytest"
|
||||
} else {
|
||||
info.BuildTool = "pip"
|
||||
info.TestCmd = "python -m pytest"
|
||||
}
|
||||
} else if fileExists(dir, "pom.xml") {
|
||||
info.Language = "java"
|
||||
info.BuildTool = "maven"
|
||||
info.TestCmd = "mvn test"
|
||||
} else if fileExists(dir, "build.gradle") || fileExists(dir, "build.gradle.kts") {
|
||||
info.Language = "java"
|
||||
info.BuildTool = "gradle"
|
||||
info.TestCmd = "gradle test"
|
||||
} else if fileExists(dir, "Makefile") {
|
||||
info.BuildTool = "make"
|
||||
info.TestCmd = detectMakeTestCmd(dir)
|
||||
}
|
||||
|
||||
// Detect CI
|
||||
if dirExists(dir, ".github", "workflows") {
|
||||
info.HasCI = true
|
||||
info.CIProvider = "github-actions"
|
||||
} else if fileExists(dir, ".gitlab-ci.yml") {
|
||||
info.HasCI = true
|
||||
info.CIProvider = "gitlab"
|
||||
} else if dirExists(dir, ".circleci") {
|
||||
info.HasCI = true
|
||||
info.CIProvider = "circleci"
|
||||
}
|
||||
|
||||
// Detect linter
|
||||
if fileExists(dir, ".eslintrc.json") || fileExists(dir, ".eslintrc.js") || fileExists(dir, "eslint.config.js") || fileExists(dir, "eslint.config.mjs") {
|
||||
info.HasLinter = true
|
||||
} else if fileExists(dir, ".golangci.yml") || fileExists(dir, ".golangci.yaml") {
|
||||
info.HasLinter = true
|
||||
} else if fileExists(dir, ".prettierrc") || fileExists(dir, ".prettierrc.json") {
|
||||
info.HasLinter = true
|
||||
} else if fileExists(dir, "rustfmt.toml") || fileExists(dir, ".rustfmt.toml") {
|
||||
info.HasLinter = true
|
||||
} else if fileExists(dir, ".flake8") || fileExists(dir, "ruff.toml") || fileExists(dir, ".ruff.toml") {
|
||||
info.HasLinter = true
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// SuggestedConventions returns convention titles from the library that match
|
||||
// the detected project info. Returns a map of title → customized content.
|
||||
func SuggestedConventions(info ProjectInfo) map[string]string {
|
||||
suggestions := make(map[string]string)
|
||||
|
||||
// Build convention
|
||||
if info.BuildTool != "" {
|
||||
var buildCmd string
|
||||
switch info.BuildTool {
|
||||
case "make":
|
||||
buildCmd = "make build"
|
||||
case "npm":
|
||||
buildCmd = "npm run build"
|
||||
case "yarn":
|
||||
buildCmd = "yarn build"
|
||||
case "pnpm":
|
||||
buildCmd = "pnpm build"
|
||||
case "cargo":
|
||||
buildCmd = "cargo build"
|
||||
case "go":
|
||||
buildCmd = "go build ./..."
|
||||
case "maven":
|
||||
buildCmd = "mvn compile"
|
||||
case "gradle":
|
||||
buildCmd = "gradle build"
|
||||
default:
|
||||
buildCmd = info.BuildTool + " build"
|
||||
}
|
||||
suggestions["Rebuild after code changes"] = "After modifying source code, run `" + buildCmd + "` to verify everything compiles and builds successfully."
|
||||
}
|
||||
|
||||
// Test convention
|
||||
if info.TestCmd != "" {
|
||||
suggestions["Run tests before completing tasks"] = "Run the project's test suite (`" + info.TestCmd + "`) before marking any task as done. If tests fail, fix them before completing the task."
|
||||
}
|
||||
|
||||
// Linter convention
|
||||
if info.HasLinter {
|
||||
suggestions["Run linter before committing"] = "Run the project's linter/formatter before committing code to ensure consistent code style."
|
||||
}
|
||||
|
||||
// CI convention
|
||||
if info.HasCI {
|
||||
suggestions["Verify locally before PR"] = "Before creating a PR, verify the changes work locally: build succeeds, tests pass, and the feature works as expected."
|
||||
}
|
||||
|
||||
// Always suggest these general ones
|
||||
suggestions["Commit after task completion"] = "Create a git commit with a descriptive message after completing each discrete unit of work. Reference the task slug or item number in the commit message."
|
||||
suggestions["Update task status when starting work"] = "When starting work on a task, update its status to in-progress: `pad update <slug> --status in-progress`"
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
func fileExists(dir, name string) bool {
|
||||
_, err := os.Stat(filepath.Join(dir, name))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func dirExists(parts ...string) bool {
|
||||
info, err := os.Stat(filepath.Join(parts...))
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
func detectMakeTestCmd(dir string) string {
|
||||
data, err := os.ReadFile(filepath.Join(dir, "Makefile"))
|
||||
if err != nil {
|
||||
return "make test"
|
||||
}
|
||||
content := string(data)
|
||||
// Check if Makefile has a test target
|
||||
if strings.Contains(content, "test:") || strings.Contains(content, "test :") {
|
||||
return "make test"
|
||||
}
|
||||
// Fall back to language-specific if no test target
|
||||
if strings.Contains(content, "go test") {
|
||||
return "go test ./..."
|
||||
}
|
||||
return "make test"
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -113,6 +114,12 @@ func (s *Server) handleCreateItem(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve relation fields (slugs/refs → UUIDs) before validation
|
||||
if err := s.resolveRelationFields(workspaceID, fieldMap, schema); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := items.ValidateFields(fieldMap, schema); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "validation_error", err.Error())
|
||||
return
|
||||
@@ -207,6 +214,12 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve relation fields (slugs/refs → UUIDs) before validation
|
||||
if err := s.resolveRelationFields(workspaceID, fieldMap, schema); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := items.ValidateFields(fieldMap, schema); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "validation_error", err.Error())
|
||||
return
|
||||
@@ -365,6 +378,57 @@ func (s *Server) handleGetItemTasks(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, tasks)
|
||||
}
|
||||
|
||||
// resolveRelationFields resolves slugs, PREFIX-NUMBER refs, and other identifiers
|
||||
// in relation fields to their canonical UUIDs. This allows clients to send
|
||||
// human-readable identifiers (e.g. --field phase=workspace-onboarding) and have
|
||||
// them stored as UUIDs that the dashboard and queries expect.
|
||||
func (s *Server) resolveRelationFields(workspaceID string, fields map[string]any, schema models.CollectionSchema) error {
|
||||
for _, def := range schema.Fields {
|
||||
if def.Type != "relation" {
|
||||
continue
|
||||
}
|
||||
val, exists := fields[def.Key]
|
||||
if !exists || val == nil {
|
||||
continue
|
||||
}
|
||||
strVal, ok := val.(string)
|
||||
if !ok || strVal == "" {
|
||||
continue
|
||||
}
|
||||
// Already a UUID — nothing to resolve
|
||||
if isUUID(strVal) {
|
||||
continue
|
||||
}
|
||||
// Resolve the identifier (slug, PREFIX-NUMBER, etc.) to an item
|
||||
item, err := s.store.ResolveItem(workspaceID, strVal)
|
||||
if err != nil {
|
||||
return fmt.Errorf("field %q: failed to resolve %q: %w", def.Key, strVal, err)
|
||||
}
|
||||
if item == nil {
|
||||
return fmt.Errorf("field %q: item %q not found", def.Key, strVal)
|
||||
}
|
||||
fields[def.Key] = item.ID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isUUID checks if a string looks like a UUID (8-4-4-4-12 hex).
|
||||
func isUUID(s string) bool {
|
||||
if len(s) != 36 {
|
||||
return false
|
||||
}
|
||||
for i, c := range s {
|
||||
if i == 8 || i == 13 || i == 18 || i == 23 {
|
||||
if c != '-' {
|
||||
return false
|
||||
}
|
||||
} else if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// autoPopulateDates auto-fills start_date/end_date when status changes to active/completed.
|
||||
// Only sets dates if the schema defines those date fields and the field is currently empty.
|
||||
func autoPopulateDates(newFields map[string]any, existingFieldsJSON string, schema models.CollectionSchema) {
|
||||
|
||||
@@ -69,6 +69,10 @@ Interpret the user's intent and route to the appropriate action. Here are common
|
||||
**Retrospective:**
|
||||
- "phase 2 is done, let's retro" → Review completed work, save retrospective
|
||||
|
||||
**Onboarding:**
|
||||
- "scan this codebase" / "set up my workspace" → Codebase analysis + onboarding workflow (see below)
|
||||
- "what conventions should this project follow?" → Analyze tooling, suggest conventions from the library
|
||||
|
||||
## Before Performing Work
|
||||
|
||||
When you are about to take action (implement code, complete a task, create a PR, etc.), load the relevant conventions and playbooks FIRST:
|
||||
@@ -195,6 +199,25 @@ All commands support `--format json` (for parsing) or `--format table` (default,
|
||||
3. Run `pad status --format json` for blockers/attention items
|
||||
4. Present as: Yesterday / Today / Blockers format
|
||||
|
||||
### Onboarding: "Scan this codebase" / "Set up my workspace"
|
||||
|
||||
1. **Check workspace state:** `pad status --format json` — if the workspace already has items, ask if they want to add more or start fresh sections.
|
||||
2. **Analyze the codebase:** Read key project files to understand the project:
|
||||
- `README.md` or `README` — project overview, setup instructions
|
||||
- `CLAUDE.md` — existing AI/agent instructions
|
||||
- Build config: `Makefile`, `package.json`, `go.mod`, `Cargo.toml`, `pyproject.toml`, `pom.xml`
|
||||
- CI config: `.github/workflows/`, `.gitlab-ci.yml`, `.circleci/`
|
||||
- Directory structure: `ls` the top-level directories to understand the layout
|
||||
3. **Detect project type and tooling:**
|
||||
- Language: Go, Node/TypeScript, Rust, Python, Java, etc.
|
||||
- Build system: make, npm, cargo, pip, maven, etc.
|
||||
- Test runner: what command runs the tests?
|
||||
- Linter/formatter: what tools enforce code style?
|
||||
4. **Suggest conventions:** Based on the detected tooling, suggest conventions from the library. Customize the content with the actual commands found in the project (e.g., "Run `make test`" not just "Run the test suite"). Present as a checklist and ask which to activate.
|
||||
5. **Draft an architecture doc:** Summarize the project structure, tech stack, key directories, and how the pieces fit together. Offer to save as a Doc item.
|
||||
6. **Propose an initial phase:** Based on recent git activity (`git log --oneline -20`) and any open TODOs, suggest a phase name and a few starter tasks. Ask before creating.
|
||||
7. **Always confirm before creating each item.** Show what will be created, get approval, then create.
|
||||
|
||||
### Retrospective: "Phase X is done, let's retro"
|
||||
|
||||
1. Load the phase: `pad show <phase-slug> --format markdown`
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
wsSlug: string;
|
||||
byCollection: Record<string, Record<string, number>>;
|
||||
}
|
||||
|
||||
let { wsSlug, byCollection }: Props = $props();
|
||||
|
||||
function collectionHasItems(slug: string): boolean {
|
||||
const breakdown = byCollection[slug];
|
||||
if (!breakdown) return false;
|
||||
return Object.values(breakdown).reduce((sum, n) => sum + n, 0) > 0;
|
||||
}
|
||||
|
||||
function collectionItemCount(slug: string): number {
|
||||
const breakdown = byCollection[slug];
|
||||
if (!breakdown) return 0;
|
||||
return Object.values(breakdown).reduce((sum, n) => sum + n, 0);
|
||||
}
|
||||
|
||||
interface Step {
|
||||
title: string;
|
||||
href: string;
|
||||
done: boolean;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
let steps = $derived<Step[]>([
|
||||
{
|
||||
title: 'Add project conventions',
|
||||
href: `/${wsSlug}/library`,
|
||||
done: collectionHasItems('conventions'),
|
||||
hint: '/pad what conventions should this project follow?'
|
||||
},
|
||||
{
|
||||
title: 'Create your first phase',
|
||||
href: `/${wsSlug}/new?collection=phases`,
|
||||
done: collectionHasItems('phases'),
|
||||
hint: '/pad create a phase for what I\'m working on'
|
||||
},
|
||||
{
|
||||
title: 'Add a few tasks',
|
||||
href: `/${wsSlug}/new?collection=tasks`,
|
||||
done: collectionItemCount('tasks') >= 3,
|
||||
hint: '/pad break down my current work into tasks'
|
||||
},
|
||||
{
|
||||
title: 'Write an architecture doc',
|
||||
href: `/${wsSlug}/new?collection=docs`,
|
||||
done: collectionHasItems('docs'),
|
||||
hint: '/pad document the architecture of this project'
|
||||
}
|
||||
]);
|
||||
|
||||
let completedCount = $derived(steps.filter((s) => s.done).length);
|
||||
let progressPct = $derived(Math.round((completedCount / steps.length) * 100));
|
||||
</script>
|
||||
|
||||
<div class="onboarding">
|
||||
<div class="onboarding-header">
|
||||
<h2>Set up your workspace</h2>
|
||||
<p class="subtitle">Complete these steps to get the most out of Pad.</p>
|
||||
</div>
|
||||
|
||||
<div class="progress-section">
|
||||
<span class="progress-label">{completedCount} of {steps.length} complete</span>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" style:width="{progressPct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol class="step-list">
|
||||
{#each steps as step (step.title)}
|
||||
<li class="step" class:done={step.done}>
|
||||
<div class="step-icon">
|
||||
{#if step.done}
|
||||
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor" width="20" height="20">
|
||||
<circle cx="10" cy="10" r="10" />
|
||||
<path d="M6 10.5l2.5 2.5 5.5-5.5" stroke="#fff" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="empty-icon" viewBox="0 0 20 20" width="20" height="20">
|
||||
<circle cx="10" cy="10" r="9" stroke="currentColor" stroke-width="1.5" fill="none" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="step-body">
|
||||
<a href={step.href} class="step-title">{step.title}</a>
|
||||
{#if !step.done}
|
||||
<span class="step-hint">Try: <code>{step.hint}</code></span>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<div class="onboarding-footer">
|
||||
<a href="/{wsSlug}/library" class="footer-link">Or open the library to browse conventions and playbooks</a>
|
||||
<span class="footer-muted">View web UI at http://localhost:7777</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.onboarding {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-6);
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.onboarding-header {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.onboarding-header h2 {
|
||||
font-size: 1.2em;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 var(--space-1) 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.88em;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Progress */
|
||||
.progress-section {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
display: block;
|
||||
font-size: 0.82em;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent-green);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Steps */
|
||||
.step-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-3);
|
||||
border-radius: var(--radius);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.step:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.step-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-size: 0.92em;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.step-title:hover {
|
||||
color: var(--accent-blue);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.done .step-title {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.step-hint {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.step-hint code {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
font-size: 0.92em;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.onboarding-footer {
|
||||
margin-top: var(--space-5);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
font-size: 0.85em;
|
||||
color: var(--accent-blue);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.footer-muted {
|
||||
font-size: 0.78em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@
|
||||
import { api } from '$lib/api/client';
|
||||
import { workspaceStore } from '$lib/stores/workspace.svelte';
|
||||
import { relativeTime } from '$lib/utils/markdown';
|
||||
import OnboardingChecklist from '$lib/components/OnboardingChecklist.svelte';
|
||||
import type { DashboardResponse, Collection } from '$lib/types';
|
||||
|
||||
let wsSlug = $derived(page.params.workspace ?? '');
|
||||
@@ -78,15 +79,7 @@
|
||||
</header>
|
||||
|
||||
{#if totalItems === 0}
|
||||
<div class="welcome-box">
|
||||
<div class="welcome-icon">🚀</div>
|
||||
<h2>Welcome to {workspaceStore.current?.name ?? 'your workspace'}</h2>
|
||||
<p>Your project is ready. Start by creating items or use <kbd>/pad</kbd> in Claude Code to manage your project conversationally.</p>
|
||||
<div class="welcome-actions">
|
||||
<a href="/{wsSlug}/new" class="welcome-btn primary">Create your first item</a>
|
||||
<a href="/{wsSlug}/tasks" class="welcome-btn secondary">Browse collections</a>
|
||||
</div>
|
||||
</div>
|
||||
<OnboardingChecklist {wsSlug} byCollection={dashboard.summary.by_collection} />
|
||||
{/if}
|
||||
|
||||
<!-- Collection Summary -->
|
||||
@@ -212,26 +205,6 @@
|
||||
margin: 0 auto;
|
||||
padding: var(--space-8) var(--space-6);
|
||||
}
|
||||
.welcome-box {
|
||||
text-align: center;
|
||||
padding: var(--space-10) var(--space-6);
|
||||
}
|
||||
.welcome-icon { font-size: 3em; margin-bottom: var(--space-4); }
|
||||
.welcome-box h2 { font-size: 1.3em; font-weight: 600; margin: 0 0 var(--space-2) 0; }
|
||||
.welcome-box p { font-size: 0.9em; color: var(--text-muted); margin: 0 0 var(--space-6) 0; max-width: 480px; margin-left: auto; margin-right: auto; }
|
||||
.welcome-box kbd {
|
||||
background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 3px;
|
||||
padding: 1px 5px; font-size: 0.9em; font-family: inherit;
|
||||
}
|
||||
.welcome-actions { display: flex; gap: var(--space-3); justify-content: center; flex-wrap: wrap; }
|
||||
.welcome-btn {
|
||||
padding: var(--space-2) var(--space-5); border-radius: var(--radius); font-weight: 600;
|
||||
font-size: 0.9em; text-decoration: none; transition: opacity 0.1s;
|
||||
}
|
||||
.welcome-btn.primary { background: var(--accent-blue); color: #fff; }
|
||||
.welcome-btn.secondary { background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border); }
|
||||
.welcome-btn:hover { opacity: 0.85; }
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding-top: 20vh;
|
||||
|
||||
@@ -236,6 +236,18 @@
|
||||
return counts;
|
||||
});
|
||||
|
||||
const emptyHintMap: Record<string, string> = {
|
||||
tasks: '/pad break down my current work into tasks',
|
||||
ideas: "/pad I have an idea for...",
|
||||
phases: '/pad create a phase for what I\'m working on',
|
||||
docs: '/pad document the architecture of this project',
|
||||
conventions: '/pad what conventions should this project follow?',
|
||||
playbooks: '/pad set up playbooks for our workflow',
|
||||
bugs: '/pad triage open issues in this project',
|
||||
};
|
||||
|
||||
let emptyHint = $derived(emptyHintMap[collSlug] ?? null);
|
||||
|
||||
function singularName(): string {
|
||||
if (!collection) return 'item';
|
||||
const name = collection.name;
|
||||
@@ -447,6 +459,9 @@
|
||||
<h2>No {collection.name.toLowerCase()} yet</h2>
|
||||
<p>Create your first {singularName().toLowerCase()} to get started.</p>
|
||||
<a href="/{wsSlug}/{collSlug}/new" class="empty-cta">+ Create {singularName()}</a>
|
||||
{#if emptyHint}
|
||||
<p class="empty-hint">Or try: <code>{emptyHint}</code></p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if filteredItems.length === 0 && (searchQuery || Object.keys(activeFilters).length > 0)}
|
||||
<div class="empty-state-box">
|
||||
@@ -524,6 +539,18 @@
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 var(--space-5) 0;
|
||||
}
|
||||
.empty-hint {
|
||||
font-size: 0.82em !important;
|
||||
color: var(--text-muted) !important;
|
||||
margin-top: var(--space-3) !important;
|
||||
}
|
||||
.empty-hint code {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.empty-cta {
|
||||
display: inline-block;
|
||||
background: var(--accent-blue);
|
||||
|
||||
Reference in New Issue
Block a user