fix: stabilize task and workspace creation (#1910)

This commit is contained in:
Carlos Florêncio
2026-07-24 09:55:18 +01:00
committed by GitHub
parent ae6e2f3daa
commit 1f4fabf30a
30 changed files with 1606 additions and 86 deletions
@@ -83,6 +83,58 @@ func TestTaskCreatorAdapterPersistsOriginByCreationPath(t *testing.T) {
}
}
func TestOfficeWorkspaceCreatorDoesNotBootstrapKanbanWorkflow(t *testing.T) {
_, taskSvc := newOfficeTaskAdapterHarness(t)
adapter := &taskWorkspaceCreatorAdapter{taskSvc: taskSvc}
ctx := context.Background()
if err := adapter.CreateWorkspace(ctx, "Office workspace", "Created by Office onboarding"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
workspaceID, err := adapter.FindWorkspaceIDByName(ctx, "Office workspace")
if err != nil {
t.Fatalf("FindWorkspaceIDByName: %v", err)
}
workflows, err := taskSvc.ListWorkflows(ctx, workspaceID, false)
if err != nil {
t.Fatalf("ListWorkflows: %v", err)
}
if len(workflows) != 0 {
t.Fatalf("Office workspace workflows = %d, want 0 before Office onboarding materializes them", len(workflows))
}
}
func TestCreateWorkspaceKanbanBootstrapCreatesUsableSteps(t *testing.T) {
harness := newBootStateTestHarness(t)
ctx := context.Background()
workspace, err := harness.taskSvc.CreateWorkspace(ctx, &taskservice.CreateWorkspaceRequest{
Name: "Kanban workspace",
BootstrapKanbanWorkflow: true,
})
if err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
workflows, err := harness.taskSvc.ListWorkflows(ctx, workspace.ID, false)
if err != nil {
t.Fatalf("ListWorkflows: %v", err)
}
if len(workflows) != 1 {
t.Fatalf("visible workflows = %d, want 1", len(workflows))
}
workflow := workflows[0]
if workflow.Name != "Kanban" || workflow.WorkflowTemplateID == nil || *workflow.WorkflowTemplateID != "simple" {
t.Fatalf("workflow = %+v, want visible Kanban workflow from simple template", workflow)
}
steps, err := harness.workflowSvc.ListStepsByWorkflow(ctx, workflow.ID)
if err != nil {
t.Fatalf("ListStepsByWorkflow: %v", err)
}
if len(steps) == 0 {
t.Fatal("Kanban workflow has no usable steps")
}
}
func newOfficeTaskAdapterHarness(t *testing.T) (*taskCreatorAdapter, *taskservice.Service) {
t.Helper()
dbConn, err := db.OpenSQLite(filepath.Join(t.TempDir(), "office-adapter.db"))
@@ -1642,6 +1642,7 @@ func newBootStateTestHarness(t *testing.T) bootStateTestHarness {
taskservice.RepositoryDiscoveryConfig{},
)
taskSvc.SetWorkflowStepCreator(workflowSvc)
taskSvc.SetWorkspaceBootstrapper(taskRepo)
taskSvc.SetWorkflowStepGetter(&workflowStepGetterAdapter{svc: workflowSvc})
taskSvc.SetStartStepResolver(&startStepResolverAdapter{svc: workflowSvc})
workflowSvc.SetWorkflowProvider(&workflowProviderAdapter{svc: taskSvc})
@@ -87,6 +87,9 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
// Wire workflow step creator to task service for board creation
taskSvc.SetWorkflowStepCreator(workflowSvc)
// Standard Kanban workspace creation is coordinated by the task SQLite
// repository so workspace, workflow, and steps share one transaction.
taskSvc.SetWorkspaceBootstrapper(repos.Task)
// Wire workflow step getter to task service for MoveTask
taskSvc.SetWorkflowStepGetter(&workflowStepGetterAdapter{svc: workflowSvc})
@@ -98,6 +98,7 @@ func NewTestServer(t *testing.T) *TestServer {
Reviews: taskRepo,
}, eventBus, log, taskservice.RepositoryDiscoveryConfig{})
taskSvc.SetWorkflowStepCreator(workflowSvc)
taskSvc.SetWorkspaceBootstrapper(taskRepo)
// Create WebSocket gateway
gateway := gateways.NewGateway(log)
@@ -37,7 +37,8 @@ func TestMultipleClients(t *testing.T) {
require.NoError(t, err)
workflowID := workflowPayload["id"].(string)
// Client 2 can see the workflow (filter by workspace to avoid default workflow)
// Client 2 can see both the workspace's default Kanban workflow and the
// additional workflow created by client 1.
listResp, err := client2.SendRequest("c2-list-1", ws.ActionWorkflowList, map[string]interface{}{
"workspace_id": workspaceID,
})
@@ -49,7 +50,7 @@ func TestMultipleClients(t *testing.T) {
workflows, ok := listPayload["workflows"].([]interface{})
require.True(t, ok)
assert.Len(t, workflows, 1)
assert.Len(t, workflows, 2)
// Client 2 lists workflow steps (created automatically with workflow)
stepResp, err := client2.SendRequest("c2-step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{
@@ -301,6 +301,7 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer {
Reviews: taskRepo,
}, eventBus, log, taskservice.RepositoryDiscoveryConfig{})
taskSvc.SetWorkflowStepCreator(workflowSvc)
taskSvc.SetWorkspaceBootstrapper(taskRepo)
// Create simulated agent manager
agentManager := NewSimulatedAgentManager(eventBus, log)
@@ -51,7 +51,7 @@ func TestWorkflowCRUD(t *testing.T) {
workflows, ok := payload["workflows"].([]interface{})
require.True(t, ok)
assert.Len(t, workflows, 1)
assert.Len(t, workflows, 2)
})
}
@@ -101,6 +101,7 @@ func (h *WorkspaceHandlers) httpCreateWorkspace(c *gin.Context) {
DefaultEnvironmentID: body.DefaultEnvironmentID,
DefaultAgentProfileID: body.DefaultAgentProfileID,
DefaultConfigAgentProfileID: body.DefaultConfigAgentProfileID,
BootstrapKanbanWorkflow: true,
})
if err != nil {
handleNotFound(c, h.logger, err, "workspace not created")
@@ -221,6 +222,7 @@ func (h *WorkspaceHandlers) wsCreateWorkspace(ctx context.Context, msg *ws.Messa
DefaultEnvironmentID: req.DefaultEnvironmentID,
DefaultAgentProfileID: req.DefaultAgentProfileID,
DefaultConfigAgentProfileID: req.DefaultConfigAgentProfileID,
BootstrapKanbanWorkflow: true,
})
if err != nil {
h.logger.Error("failed to create workspace", zap.Error(err))
@@ -30,6 +30,82 @@ type workspaceDeleteRepo struct {
getErr error
}
type recordingWorkspaceBootstrapper struct {
workspaces []*models.Workspace
err error
}
func (b *recordingWorkspaceBootstrapper) CreateWorkspaceWithKanban(_ context.Context, workspace *models.Workspace) (*models.Workflow, error) {
b.workspaces = append(b.workspaces, workspace)
if b.err != nil {
return nil, b.err
}
templateID := "simple"
return &models.Workflow{ID: "kanban", WorkspaceID: workspace.ID, Name: "Kanban", WorkflowTemplateID: &templateID}, nil
}
func newWorkspaceBootstrapService(t *testing.T, repo *mockRepository, bootstrapper service.WorkspaceBootstrapper) (*service.Service, *logger.Logger) {
t.Helper()
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"})
require.NoError(t, err)
svc := service.NewService(service.Repos{
Workspaces: repo,
Tasks: repo, TaskRepos: repo, Workflows: repo, Messages: repo, Turns: repo, Sessions: repo,
GitSnapshots: repo, RepoEntities: repo, Executors: repo, Environments: repo,
TaskEnvironments: repo, Reviews: repo,
}, nil, log, service.RepositoryDiscoveryConfig{})
svc.SetWorkspaceBootstrapper(bootstrapper)
return svc, log
}
func TestHTTPCreateWorkspaceCreatesKanbanWorkflow(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &mockRepository{}
bootstrapper := &recordingWorkspaceBootstrapper{}
svc, log := newWorkspaceBootstrapService(t, repo, bootstrapper)
h := NewWorkspaceHandlers(svc, log)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/workspaces", strings.NewReader(`{"name":"Kanban"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.httpCreateWorkspace(c)
require.Equal(t, http.StatusOK, rec.Code)
require.Len(t, bootstrapper.workspaces, 1)
}
func TestWSCreateWorkspaceCreatesKanbanWorkflow(t *testing.T) {
repo := &mockRepository{}
bootstrapper := &recordingWorkspaceBootstrapper{}
svc, log := newWorkspaceBootstrapService(t, repo, bootstrapper)
h := NewWorkspaceHandlers(svc, log)
req, err := ws.NewRequest("request-1", ws.ActionWorkspaceCreate, map[string]string{"name": "Kanban"})
require.NoError(t, err)
resp, err := h.wsCreateWorkspace(context.Background(), req)
require.NoError(t, err)
require.Equal(t, ws.MessageTypeResponse, resp.Type)
require.Len(t, bootstrapper.workspaces, 1)
}
func TestHTTPCreateWorkspaceFailsWhenKanbanBootstrapFails(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &mockRepository{}
svc, log := newWorkspaceBootstrapService(t, repo, &recordingWorkspaceBootstrapper{err: errors.New("template unavailable")})
h := NewWorkspaceHandlers(svc, log)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/workspaces", strings.NewReader(`{"name":"Kanban"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.httpCreateWorkspace(c)
require.Equal(t, http.StatusInternalServerError, rec.Code)
}
func (r *workspaceDeleteRepo) GetWorkspace(_ context.Context, id string) (*models.Workspace, error) {
r.getCalls++
if r.getErr != nil {
@@ -33,6 +33,11 @@ func (r *Repository) RemoveTaskFromWorkflow(ctx context.Context, taskID, workflo
// CreateWorkflow creates a new workflow
func (r *Repository) CreateWorkflow(ctx context.Context, workflow *models.Workflow) error {
r.prepareWorkflow(workflow)
return r.insertWorkflow(ctx, r.db, workflow)
}
func (r *Repository) prepareWorkflow(workflow *models.Workflow) {
if workflow.ID == "" {
workflow.ID = uuid.New().String()
}
@@ -40,10 +45,13 @@ func (r *Repository) CreateWorkflow(ctx context.Context, workflow *models.Workfl
workflow.CreatedAt = now
workflow.UpdatedAt = now
}
func (r *Repository) insertWorkflow(ctx context.Context, exec sqlx.ExtContext, workflow *models.Workflow) error {
// Auto-assign sort_order as max+1 within the workspace.
// Use writer connection (r.db) to avoid stale reads under concurrent creation.
// The caller's transaction keeps the read and insert coherent.
var maxOrder int
err := r.db.QueryRowContext(ctx, r.db.Rebind(
err := exec.QueryRowxContext(ctx, r.db.Rebind(
`SELECT COALESCE(MAX(sort_order), -1) FROM workflows WHERE workspace_id = ?`,
), workflow.WorkspaceID).Scan(&maxOrder)
if err != nil {
@@ -51,7 +59,7 @@ func (r *Repository) CreateWorkflow(ctx context.Context, workflow *models.Workfl
}
workflow.SortOrder = maxOrder + 1
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
_, err = exec.ExecContext(ctx, r.db.Rebind(`
INSERT INTO workflows (id, workspace_id, name, description, agent_profile_id, workflow_template_id, sort_order, hidden, style, source, source_path, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), workflow.ID, workflow.WorkspaceID, workflow.Name, workflow.Description, workflow.AgentProfileID, workflow.WorkflowTemplateID, workflow.SortOrder, dialect.BoolToInt(workflow.Hidden), normalizeWorkflowStyle(workflow.Style), normalizeWorkflowSource(workflow.Source), workflow.SourcePath, workflow.CreatedAt, workflow.UpdatedAt)
@@ -16,6 +16,11 @@ import (
// CreateWorkspace creates a new workspace
func (r *Repository) CreateWorkspace(ctx context.Context, workspace *models.Workspace) error {
r.prepareWorkspace(workspace)
return r.insertWorkspace(ctx, r.db, workspace)
}
func (r *Repository) prepareWorkspace(workspace *models.Workspace) {
if workspace.ID == "" {
workspace.ID = uuid.New().String()
}
@@ -25,8 +30,10 @@ func (r *Repository) CreateWorkspace(ctx context.Context, workspace *models.Work
if workspace.TaskPrefix == "" {
workspace.TaskPrefix = "KAN"
}
}
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
func (r *Repository) insertWorkspace(ctx context.Context, exec sqlx.ExtContext, workspace *models.Workspace) error {
_, err := exec.ExecContext(ctx, r.db.Rebind(`
INSERT INTO workspaces (
id,
name,
@@ -192,6 +199,16 @@ func (r *Repository) deleteWorkspaceCascade(
`), id); err != nil {
return nil, nil, err
}
if _, err := tx.ExecContext(ctx, r.db.Rebind(`
DELETE FROM workflow_steps
WHERE workflow_id IN (
SELECT id
FROM workflows
WHERE workspace_id = ?
)
`), id); err != nil {
return nil, nil, err
}
if _, err := tx.ExecContext(ctx, r.db.Rebind(`
DELETE FROM workflows
WHERE workspace_id = ?
@@ -0,0 +1,106 @@
package sqlite
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
workflowcfg "github.com/kandev/kandev/config/workflows"
"github.com/kandev/kandev/internal/db/dialect"
"github.com/kandev/kandev/internal/task/models"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
)
const kanbanTemplateID = "simple"
// CreateWorkspaceWithKanban persists a standard Kanban workspace, its default
// workflow, and all template-derived steps in one transaction.
func (r *Repository) CreateWorkspaceWithKanban(ctx context.Context, workspace *models.Workspace) (*models.Workflow, error) {
template, err := kanbanTemplate()
if err != nil {
return nil, err
}
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
r.prepareWorkspace(workspace)
if err := r.insertWorkspace(ctx, tx, workspace); err != nil {
return nil, fmt.Errorf("create workspace: %w", err)
}
workflow := &models.Workflow{
WorkspaceID: workspace.ID,
Name: "Kanban",
WorkflowTemplateID: &template.ID,
}
r.prepareWorkflow(workflow)
if err := r.insertWorkflow(ctx, tx, workflow); err != nil {
return nil, fmt.Errorf("create Kanban workflow: %w", err)
}
if err := r.insertTemplateSteps(ctx, tx, workflow.ID, template); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return workflow, nil
}
func kanbanTemplate() (*wfmodels.WorkflowTemplate, error) {
templates, err := workflowcfg.LoadTemplates()
if err != nil {
return nil, fmt.Errorf("load Kanban template: %w", err)
}
for _, template := range templates {
if template.ID == kanbanTemplateID {
return template, nil
}
}
return nil, fmt.Errorf("kanban template %q not found", kanbanTemplateID)
}
func (r *Repository) insertTemplateSteps(ctx context.Context, tx *sqlx.Tx, workflowID string, template *wfmodels.WorkflowTemplate) error {
idMap := make(map[string]string, len(template.Steps))
for _, stepDef := range template.Steps {
idMap[stepDef.ID] = uuid.NewString()
}
now := time.Now().UTC()
for _, stepDef := range template.Steps {
events, err := json.Marshal(wfmodels.RemapStepEvents(stepDef.Events, idMap))
if err != nil {
return fmt.Errorf("marshal Kanban step %q: %w", stepDef.Name, err)
}
if _, err := tx.ExecContext(ctx, r.db.Rebind(`
INSERT INTO workflow_steps (
id, workflow_id, name, position, color, prompt, events,
allow_manual_move, is_start_step, show_in_command_panel,
auto_archive_after_hours, agent_profile_id, stage_type,
auto_advance_requires_signal, wip_limit, pull_from_step_id,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), idMap[stepDef.ID], workflowID, stepDef.Name, stepDef.Position, stepDef.Color, stepDef.Prompt, string(events),
dialect.BoolToInt(stepDef.AllowManualMove), dialect.BoolToInt(stepDef.IsStartStep), dialect.BoolToInt(stepDef.ShowInCommandPanel),
stepDef.AutoArchiveAfterHours, stepDef.AgentProfileID, normalizeBootstrapStageType(stepDef.StageType),
dialect.BoolToInt(stepDef.AutoAdvanceRequiresSignal), stepDef.WIPLimit, wfmodels.RemapStepID(stepDef.PullFromStepID, idMap), now, now); err != nil {
return fmt.Errorf("create Kanban step %q: %w", stepDef.Name, err)
}
}
return nil
}
func normalizeBootstrapStageType(stageType wfmodels.StageType) string {
switch stageType {
case wfmodels.StageTypeWork, wfmodels.StageTypeReview, wfmodels.StageTypeApproval, wfmodels.StageTypeCustom:
return string(stageType)
default:
return string(wfmodels.StageTypeCustom)
}
}
@@ -5,8 +5,12 @@ import (
"errors"
"testing"
"github.com/google/uuid"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository/repoerrors"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
workflowrepo "github.com/kandev/kandev/internal/workflow/repository"
)
func TestDeleteWorkspaceCascadeWithNameDeletesWorkspaceChildren(t *testing.T) {
@@ -73,6 +77,45 @@ func TestDeleteWorkspaceCascadeDeletesWorkspaceChildren(t *testing.T) {
assertNoWorkspaceCascadeDependents(t, repo)
}
func TestDeleteWorkspaceCascadeDeletesBootstrappedWorkflowSteps(t *testing.T) {
ctx := context.Background()
repo := newRepoForHealTests(t)
if _, err := workflowrepo.NewWithDB(repo.db, repo.db, nil); err != nil {
t.Fatalf("initialize workflow repository: %v", err)
}
workspace := &models.Workspace{ID: "ws-kanban-delete", Name: "Delete Kanban"}
workflow, err := repo.CreateWorkspaceWithKanban(ctx, workspace)
if err != nil {
t.Fatalf("CreateWorkspaceWithKanban: %v", err)
}
var steps int
if err := repo.db.QueryRow(`
SELECT COUNT(*)
FROM workflow_steps
WHERE workflow_id = ?
`, workflow.ID).Scan(&steps); err != nil {
t.Fatalf("count bootstrapped steps: %v", err)
}
if steps != 4 {
t.Fatalf("bootstrapped steps = %d, want 4", steps)
}
if _, _, err := repo.DeleteWorkspaceCascade(ctx, workspace.ID); err != nil {
t.Fatalf("DeleteWorkspaceCascade: %v", err)
}
if err := repo.db.QueryRow(`
SELECT COUNT(*)
FROM workflow_steps
WHERE workflow_id = ?
`, workflow.ID).Scan(&steps); err != nil {
t.Fatalf("count remaining steps: %v", err)
}
if steps != 0 {
t.Fatalf("remaining workflow steps = %d, want 0", steps)
}
}
func TestDeleteWorkspaceCascadeWithNameRejectsMismatchedName(t *testing.T) {
ctx := context.Background()
repo := newRepoForHealTests(t)
@@ -124,6 +167,295 @@ func TestDeleteWorkspaceCascadeWithNameRollsBackWhenChildDeleteFails(t *testing.
}
}
func TestCreateWorkspaceWithKanbanRollsBackAllRowsWhenStepInsertFails(t *testing.T) {
ctx := context.Background()
repo := newRepoForHealTests(t)
if _, err := workflowrepo.NewWithDB(repo.db, repo.db, nil); err != nil {
t.Fatalf("initialize workflow repository: %v", err)
}
if _, err := repo.db.Exec(`
CREATE TRIGGER fail_kanban_step
BEFORE INSERT ON workflow_steps
WHEN NEW.name = 'In Progress'
BEGIN
SELECT RAISE(ABORT, 'step insert blocked');
END
`); err != nil {
t.Fatalf("create failure trigger: %v", err)
}
stepsBefore := countWorkflowSteps(t, repo)
_, err := repo.CreateWorkspaceWithKanban(ctx, &models.Workspace{ID: "ws-bootstrap", Name: "Bootstrap"})
if err == nil {
t.Fatal("CreateWorkspaceWithKanban succeeded despite step insert failure")
}
assertBootstrapRowsAbsent(t, repo, "ws-bootstrap", stepsBefore)
}
func TestCreateWorkspaceWithKanbanRollsBackWhenContextCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
repo := newRepoForHealTests(t)
if _, err := workflowrepo.NewWithDB(repo.db, repo.db, nil); err != nil {
t.Fatalf("initialize workflow repository: %v", err)
}
_, err := repo.CreateWorkspaceWithKanban(ctx, &models.Workspace{ID: "ws-cancelled", Name: "Cancelled"})
if err == nil {
t.Fatal("CreateWorkspaceWithKanban succeeded with a cancelled context")
}
assertBootstrapRowsAbsent(t, repo, "ws-cancelled", countWorkflowSteps(t, repo))
}
func TestCreateWorkspaceWithKanbanUsesSimpleTemplate(t *testing.T) {
ctx := context.Background()
repo := newRepoForHealTests(t)
workflowStore := newWorkflowStore(t, repo)
workflow, err := repo.CreateWorkspaceWithKanban(ctx, &models.Workspace{ID: "ws-kanban", Name: "Kanban"})
if err != nil {
t.Fatalf("CreateWorkspaceWithKanban: %v", err)
}
if workflow.WorkflowTemplateID == nil || *workflow.WorkflowTemplateID != kanbanTemplateID {
t.Fatalf("workflow template = %v, want %q", workflow.WorkflowTemplateID, kanbanTemplateID)
}
steps, err := workflowStore.ListStepsByWorkflow(ctx, workflow.ID)
if err != nil {
t.Fatalf("ListStepsByWorkflow: %v", err)
}
if len(steps) != 4 {
t.Fatalf("Kanban steps = %d, want 4", len(steps))
}
template, err := kanbanTemplate()
if err != nil {
t.Fatalf("kanbanTemplate: %v", err)
}
if len(template.Steps) != 4 {
t.Fatalf("simple template steps = %d, want 4", len(template.Steps))
}
byName := workflowStepsByName(t, steps)
if len(byName) != len(template.Steps) {
t.Fatalf("unique Kanban step names = %d, want %d", len(byName), len(template.Steps))
}
for _, definition := range template.Steps {
assertKanbanStep(t, byName[definition.Name], workflow.ID, definition)
}
reviewID := byName["Review"].ID
if got := stepIDFromTurnComplete(t, byName["Backlog"], wfmodels.OnTurnCompleteMoveToStep); got != reviewID {
t.Fatalf("Backlog move target = %q, want Review ID %q", got, reviewID)
}
if got := stepIDFromTurnComplete(t, byName["In Progress"], wfmodels.OnTurnCompleteMoveToStep); got != reviewID {
t.Fatalf("In Progress move target = %q, want Review ID %q", got, reviewID)
}
if got := stepIDFromTurnStart(t, byName["Done"], wfmodels.OnTurnStartMoveToStep); got != byName["In Progress"].ID {
t.Fatalf("Done move target = %q, want In Progress ID %q", got, byName["In Progress"].ID)
}
assertKanbanEventTypes(t, byName)
}
func TestInsertTemplateStepsPreservesOptionalFieldsAndRemapsReferences(t *testing.T) {
ctx := context.Background()
repo := newRepoForHealTests(t)
workflowStore := newWorkflowStore(t, repo)
workspace := &models.Workspace{ID: "ws-synthetic", Name: "Synthetic"}
if err := repo.CreateWorkspace(ctx, workspace); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
workflow := &models.Workflow{ID: "wf-synthetic", WorkspaceID: workspace.ID, Name: "Synthetic"}
if err := repo.CreateWorkflow(ctx, workflow); err != nil {
t.Fatalf("CreateWorkflow: %v", err)
}
template := &wfmodels.WorkflowTemplate{Steps: []wfmodels.StepDefinition{
{
ID: "source",
Name: "Source",
Position: 4,
Color: "bg-purple-500",
Prompt: "keep every field",
AllowManualMove: false,
IsStartStep: true,
ShowInCommandPanel: false,
AutoArchiveAfterHours: 72,
AgentProfileID: "agent-profile",
StageType: wfmodels.StageTypeReview,
AutoAdvanceRequiresSignal: true,
WIPLimit: 3,
PullFromStepID: "target",
Events: wfmodels.StepEvents{
OnEnter: []wfmodels.OnEnterAction{{Type: wfmodels.OnEnterAutoStartAgent}},
OnTurnStart: []wfmodels.OnTurnStartAction{{Type: wfmodels.OnTurnStartMoveToStep, Config: map[string]any{"step_id": "target"}}},
OnTurnComplete: []wfmodels.OnTurnCompleteAction{{Type: wfmodels.OnTurnCompleteMoveToStep, Config: map[string]any{"step_id": "target"}}},
OnComment: []wfmodels.GenericAction{{Type: wfmodels.GenericActionMoveToStep, Config: map[string]any{"step_id": "target"}}},
},
},
{ID: "target", Name: "Target", Position: 5, Color: "bg-green-500"},
}}
tx, err := repo.db.BeginTxx(ctx, nil)
if err != nil {
t.Fatalf("begin transaction: %v", err)
}
defer func() { _ = tx.Rollback() }()
if err := repo.insertTemplateSteps(ctx, tx, workflow.ID, template); err != nil {
t.Fatalf("insertTemplateSteps: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit template steps: %v", err)
}
steps, err := workflowStore.ListStepsByWorkflow(ctx, workflow.ID)
if err != nil {
t.Fatalf("ListStepsByWorkflow: %v", err)
}
byName := workflowStepsByName(t, steps)
source := byName["Source"]
target := byName["Target"]
if source.ID == "source" || target.ID == "target" || source.ID == target.ID {
t.Fatalf("generated IDs = source:%q target:%q, want distinct non-template IDs", source.ID, target.ID)
}
for _, step := range []*wfmodels.WorkflowStep{source, target} {
if _, err := uuid.Parse(step.ID); err != nil {
t.Fatalf("step ID %q is not a UUID: %v", step.ID, err)
}
}
if source.Prompt != "keep every field" || source.Color != "bg-purple-500" || source.Position != 4 ||
source.AllowManualMove || !source.IsStartStep || source.ShowInCommandPanel ||
source.AutoArchiveAfterHours != 72 || source.AgentProfileID != "agent-profile" ||
source.StageType != wfmodels.StageTypeReview || !source.AutoAdvanceRequiresSignal || source.WIPLimit != 3 {
t.Fatalf("optional fields were not persisted faithfully: %#v", source)
}
if source.PullFromStepID != target.ID {
t.Fatalf("pull source = %q, want remapped target ID %q", source.PullFromStepID, target.ID)
}
if got := stepIDFromTurnStart(t, source, wfmodels.OnTurnStartMoveToStep); got != target.ID {
t.Fatalf("on_turn_start target = %q, want %q", got, target.ID)
}
if got := stepIDFromTurnComplete(t, source, wfmodels.OnTurnCompleteMoveToStep); got != target.ID {
t.Fatalf("on_turn_complete target = %q, want %q", got, target.ID)
}
if got := stepIDFromComment(t, source); got != target.ID {
t.Fatalf("on_comment target = %q, want %q", got, target.ID)
}
}
func newWorkflowStore(t *testing.T, repo *Repository) *workflowrepo.Repository {
t.Helper()
store, err := workflowrepo.NewWithDB(repo.db, repo.db, nil)
if err != nil {
t.Fatalf("initialize workflow repository: %v", err)
}
return store
}
func workflowStepsByName(t *testing.T, steps []*wfmodels.WorkflowStep) map[string]*wfmodels.WorkflowStep {
t.Helper()
byName := make(map[string]*wfmodels.WorkflowStep, len(steps))
for _, step := range steps {
byName[step.Name] = step
}
return byName
}
func assertKanbanStep(t *testing.T, step *wfmodels.WorkflowStep, workflowID string, definition wfmodels.StepDefinition) {
t.Helper()
if step == nil {
t.Fatal("expected Kanban step is missing")
}
if step.WorkflowID != workflowID || step.Position != definition.Position || step.Color != definition.Color ||
step.Prompt != definition.Prompt || step.AllowManualMove != definition.AllowManualMove ||
step.IsStartStep != definition.IsStartStep || step.ShowInCommandPanel != definition.ShowInCommandPanel ||
step.AutoArchiveAfterHours != definition.AutoArchiveAfterHours || step.AgentProfileID != definition.AgentProfileID ||
step.StageType != wfmodels.StageType(normalizeBootstrapStageType(definition.StageType)) ||
step.AutoAdvanceRequiresSignal != definition.AutoAdvanceRequiresSignal || step.WIPLimit != definition.WIPLimit ||
step.PullFromStepID != definition.PullFromStepID {
t.Fatalf("Kanban step %q was not persisted faithfully: %#v", step.Name, step)
}
if _, err := uuid.Parse(step.ID); err != nil {
t.Fatalf("Kanban step %q ID %q is not a UUID: %v", step.Name, step.ID, err)
}
}
func assertKanbanEventTypes(t *testing.T, steps map[string]*wfmodels.WorkflowStep) {
t.Helper()
backlog := steps["Backlog"]
inProgress := steps["In Progress"]
review := steps["Review"]
if len(backlog.Events.OnTurnStart) != 1 || backlog.Events.OnTurnStart[0].Type != wfmodels.OnTurnStartMoveToNext {
t.Fatalf("Backlog on_turn_start = %#v, want move_to_next", backlog.Events.OnTurnStart)
}
if len(inProgress.Events.OnEnter) != 1 || inProgress.Events.OnEnter[0].Type != wfmodels.OnEnterAutoStartAgent {
t.Fatalf("In Progress on_enter = %#v, want auto_start_agent", inProgress.Events.OnEnter)
}
if len(review.Events.OnTurnStart) != 1 || review.Events.OnTurnStart[0].Type != wfmodels.OnTurnStartMoveToPrevious {
t.Fatalf("Review on_turn_start = %#v, want move_to_previous", review.Events.OnTurnStart)
}
}
func stepIDFromTurnStart(t *testing.T, step *wfmodels.WorkflowStep, expected wfmodels.OnTurnStartActionType) string {
t.Helper()
if len(step.Events.OnTurnStart) != 1 {
t.Fatalf("%s on_turn_start actions = %d, want 1", step.Name, len(step.Events.OnTurnStart))
}
if step.Events.OnTurnStart[0].Type != expected {
t.Fatalf("%s on_turn_start type = %q, want %q", step.Name, step.Events.OnTurnStart[0].Type, expected)
}
return configStepID(t, step.Name, step.Events.OnTurnStart[0].Config)
}
func stepIDFromTurnComplete(t *testing.T, step *wfmodels.WorkflowStep, expected wfmodels.OnTurnCompleteActionType) string {
t.Helper()
if len(step.Events.OnTurnComplete) != 1 {
t.Fatalf("%s on_turn_complete actions = %d, want 1", step.Name, len(step.Events.OnTurnComplete))
}
if step.Events.OnTurnComplete[0].Type != expected {
t.Fatalf("%s on_turn_complete type = %q, want %q", step.Name, step.Events.OnTurnComplete[0].Type, expected)
}
return configStepID(t, step.Name, step.Events.OnTurnComplete[0].Config)
}
func stepIDFromComment(t *testing.T, step *wfmodels.WorkflowStep) string {
t.Helper()
if len(step.Events.OnComment) != 1 {
t.Fatalf("%s on_comment actions = %d, want 1", step.Name, len(step.Events.OnComment))
}
if step.Events.OnComment[0].Type != wfmodels.GenericActionMoveToStep {
t.Fatalf("%s on_comment type = %q, want move_to_step", step.Name, step.Events.OnComment[0].Type)
}
return configStepID(t, step.Name, step.Events.OnComment[0].Config)
}
func configStepID(t *testing.T, stepName string, config map[string]interface{}) string {
t.Helper()
stepID, ok := config["step_id"].(string)
if !ok {
t.Fatalf("%s step_id config = %#v, want string", stepName, config)
}
return stepID
}
func assertBootstrapRowsAbsent(t *testing.T, repo *Repository, workspaceID string, stepsBefore int) {
t.Helper()
var workspaces, workflows int
if err := repo.db.QueryRow(`SELECT COUNT(*) FROM workspaces WHERE id = ?`, workspaceID).Scan(&workspaces); err != nil {
t.Fatalf("count workspaces: %v", err)
}
if err := repo.db.QueryRow(`SELECT COUNT(*) FROM workflows WHERE workspace_id = ?`, workspaceID).Scan(&workflows); err != nil {
t.Fatalf("count workflows: %v", err)
}
stepsAfter := countWorkflowSteps(t, repo)
if workspaces != 0 || workflows != 0 || stepsAfter != stepsBefore {
t.Fatalf("bootstrap rows = workspace:%d workflow:%d total-steps:%d, want workspace/workflow zero and steps unchanged at %d", workspaces, workflows, stepsAfter, stepsBefore)
}
}
func countWorkflowSteps(t *testing.T, repo *Repository) int {
t.Helper()
var steps int
if err := repo.db.QueryRow(`SELECT COUNT(*) FROM workflow_steps`).Scan(&steps); err != nil {
t.Fatalf("count workflow steps: %v", err)
}
return steps
}
func seedWorkspaceCascadeRows(t *testing.T, repo *Repository, workspaceID string) {
t.Helper()
ctx := context.Background()
@@ -10,6 +10,7 @@ import (
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
"github.com/kandev/kandev/internal/worktree"
@@ -114,6 +115,12 @@ type WorkflowStepCreator interface {
CreateStepsFromTemplate(ctx context.Context, workflowID, templateID string) error
}
// WorkspaceBootstrapper owns the atomic persistence of a standard Kanban
// workspace and its initial workflow state.
type WorkspaceBootstrapper interface {
CreateWorkspaceWithKanban(ctx context.Context, workspace *models.Workspace) (*models.Workflow, error)
}
// WorkflowStepGetter retrieves workflow step information.
type WorkflowStepGetter interface {
GetStep(ctx context.Context, stepID string) (*wfmodels.WorkflowStep, error)
@@ -203,6 +210,7 @@ type Service struct {
providerProber ProviderDefaultBranchProber
gitArchiveCapture GitArchiveCapture
workflowStepCreator WorkflowStepCreator
workspaceBootstrapper WorkspaceBootstrapper
workflowStepGetter WorkflowStepGetter
startStepResolver StartStepResolver
prTaskResolver PRTaskResolver
@@ -309,6 +317,10 @@ func (s *Service) SetWorkflowStepCreator(creator WorkflowStepCreator) {
s.workflowStepCreator = creator
}
func (s *Service) SetWorkspaceBootstrapper(bootstrapper WorkspaceBootstrapper) {
s.workspaceBootstrapper = bootstrapper
}
// SetWorkflowStepGetter wires the workflow step getter for MoveTask.
func (s *Service) SetWorkflowStepGetter(getter WorkflowStepGetter) {
s.workflowStepGetter = getter
@@ -101,6 +101,7 @@ type CreateWorkspaceRequest struct {
DefaultEnvironmentID *string `json:"default_environment_id,omitempty"`
DefaultAgentProfileID *string `json:"default_agent_profile_id,omitempty"`
DefaultConfigAgentProfileID *string `json:"default_config_agent_profile_id,omitempty"`
BootstrapKanbanWorkflow bool
}
// UpdateWorkspaceRequest contains the data for updating a workspace
@@ -78,13 +78,30 @@ func (s *Service) CreateWorkspace(ctx context.Context, req *CreateWorkspaceReque
DefaultConfigAgentProfileID: normalizeOptionalID(req.DefaultConfigAgentProfileID),
}
if err := s.workspaces.CreateWorkspace(ctx, workspace); err != nil {
var kanbanWorkflow *models.Workflow
if req.BootstrapKanbanWorkflow {
if s.workspaceBootstrapper == nil {
err := errors.New("workspace bootstrapper is not configured")
s.logger.Error("failed to create workspace with Kanban bootstrap", zap.Error(err))
return nil, err
}
workflow, err := s.workspaceBootstrapper.CreateWorkspaceWithKanban(ctx, workspace)
if err != nil {
s.logger.Error("failed to create workspace with Kanban bootstrap", zap.Error(err))
return nil, err
}
kanbanWorkflow = workflow
} else if err := s.workspaces.CreateWorkspace(ctx, workspace); err != nil {
s.logger.Error("failed to create workspace", zap.Error(err))
return nil, err
}
s.publishWorkspaceEvent(ctx, events.WorkspaceCreated, workspace)
s.logger.Info("workspace created", zap.String("workspace_id", workspace.ID), zap.String("name", workspace.Name))
if kanbanWorkflow != nil {
s.publishWorkflowEvent(ctx, events.WorkflowCreated, kanbanWorkflow)
s.logger.Info("workflow created", zap.String("workflow_id", kanbanWorkflow.ID), zap.String("name", kanbanWorkflow.Name))
}
return workspace, nil
}
@@ -12,12 +12,87 @@ import (
"testing"
"time"
commonlogger "github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository"
"github.com/kandev/kandev/internal/worktree"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
)
type failingWorkspaceBootstrapper struct {
err error
}
func (b *failingWorkspaceBootstrapper) CreateWorkspaceWithKanban(
context.Context,
*models.Workspace,
) (*models.Workflow, error) {
return nil, b.err
}
func TestService_CreateWorkspaceKanbanBootstrapPublishesParentBeforeWorkflow(t *testing.T) {
svc, eventBus, _ := createTestService(t)
_, err := svc.CreateWorkspace(context.Background(), &CreateWorkspaceRequest{
Name: "Kanban",
BootstrapKanbanWorkflow: true,
})
if err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
published := eventBus.GetPublishedEvents()
if len(published) != 2 {
t.Fatalf("published events = %d, want 2", len(published))
}
if published[0].Type != events.WorkspaceCreated || published[1].Type != events.WorkflowCreated {
t.Fatalf("event order = %q, %q; want workspace.created, workflow.created", published[0].Type, published[1].Type)
}
}
func TestService_CreateWorkspaceLogsKanbanBootstrapFailures(t *testing.T) {
testCases := []struct {
name string
bootstrapper WorkspaceBootstrapper
wantErr string
}{
{
name: "missing bootstrapper",
wantErr: "workspace bootstrapper is not configured",
},
{
name: "bootstrap persistence failure",
bootstrapper: &failingWorkspaceBootstrapper{err: errors.New("insert failed")},
wantErr: "insert failed",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
svc, _, _ := createTestService(t)
svc.SetWorkspaceBootstrapper(tc.bootstrapper)
core, logs := observer.New(zapcore.ErrorLevel)
log, err := commonlogger.NewFromZap(zap.New(core))
if err != nil {
t.Fatalf("create logger: %v", err)
}
svc.logger = log
_, err = svc.CreateWorkspace(context.Background(), &CreateWorkspaceRequest{
Name: "Kanban",
BootstrapKanbanWorkflow: true,
})
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("CreateWorkspace error = %v, want %q", err, tc.wantErr)
}
if logs.FilterMessage("failed to create workspace with Kanban bootstrap").Len() != 1 {
t.Fatalf("bootstrap failure log count = %d, want 1", logs.Len())
}
})
}
}
func TestService_CreateRepositoryCanonicalizesExplicitLocalPath(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
@@ -21,6 +21,7 @@ import (
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository"
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
workflowrepo "github.com/kandev/kandev/internal/workflow/repository"
"github.com/kandev/kandev/internal/worktree"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
@@ -116,6 +117,9 @@ func createTestServiceWithSessionsRepo(
if _, err := officesqlite.NewWithDB(sqlxDB, sqlxDB, nil); err != nil {
t.Fatalf("failed to apply office migrations: %v", err)
}
if _, err := workflowrepo.NewWithDB(sqlxDB, sqlxDB, nil); err != nil {
t.Fatalf("failed to initialize workflow repository: %v", err)
}
t.Cleanup(func() {
if err := sqlxDB.Close(); err != nil {
t.Errorf("failed to close sqlite db: %v", err)
@@ -142,6 +146,7 @@ func createTestServiceWithSessionsRepo(
Reviews: repo,
ResourceCleanups: repo,
}, eventBus, log, RepositoryDiscoveryConfig{})
svc.SetWorkspaceBootstrapper(repo)
if err := svc.StartTaskResourceCleanupWorker(context.Background()); err != nil {
t.Fatalf("failed to start task resource cleanup worker: %v", err)
}
@@ -3,8 +3,11 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-li
import type { ReactNode } from "react";
import type { Branch } from "@/lib/types/http";
const TOOLTIP_ROOT_TEST_ID = "tooltip-root";
vi.mock("@kandev/ui/tooltip", async () => {
const React = await import("react");
const TooltipContext = React.createContext<((open: boolean) => void) | undefined>(undefined);
return {
Tooltip: ({
open,
@@ -15,18 +18,64 @@ vi.mock("@kandev/ui/tooltip", async () => {
onOpenChange?: (open: boolean) => void;
children: ReactNode;
}) => {
React.useEffect(() => {
onOpenChange?.(true);
}, [onOpenChange]);
return (
<div data-testid="tooltip-root" data-open={String(open)}>
{children}
</div>
<TooltipContext.Provider value={onOpenChange}>
<div data-testid={TOOLTIP_ROOT_TEST_ID} data-open={String(open)}>
{children}
</div>
</TooltipContext.Provider>
);
},
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => (
<div data-testid="tooltip-content">{children}</div>
TooltipTrigger: ({
children,
onPointerEnter: triggerPointerEnter,
onPointerLeave: triggerPointerLeave,
onFocus: triggerFocus,
onBlur: triggerBlur,
...triggerProps
}: {
children: ReactNode;
onPointerEnter?: React.PointerEventHandler;
onPointerLeave?: React.PointerEventHandler;
onFocus?: React.FocusEventHandler;
onBlur?: React.FocusEventHandler;
[key: string]: unknown;
}) => {
const onOpenChange = React.useContext(TooltipContext);
const child = React.Children.only(children) as React.ReactElement<{
onPointerEnter?: React.PointerEventHandler;
onPointerLeave?: React.PointerEventHandler;
onFocus?: React.FocusEventHandler;
onBlur?: React.FocusEventHandler;
}>;
return React.cloneElement(child, {
...triggerProps,
onPointerEnter: (event) => {
triggerPointerEnter?.(event);
child.props.onPointerEnter?.(event);
onOpenChange?.(true);
},
onPointerLeave: (event) => {
triggerPointerLeave?.(event);
child.props.onPointerLeave?.(event);
onOpenChange?.(false);
},
onFocus: (event) => {
triggerFocus?.(event);
child.props.onFocus?.(event);
onOpenChange?.(true);
},
onBlur: (event) => {
triggerBlur?.(event);
child.props.onBlur?.(event);
onOpenChange?.(false);
},
});
},
TooltipContent: ({ children, className }: { children: ReactNode; className?: string }) => (
<div data-testid="tooltip-content" className={className}>
{children}
</div>
),
};
});
@@ -36,12 +85,14 @@ import {
branchToOption,
computeBranchPlaceholder,
Pill,
type PillOption,
} from "./task-create-dialog-pill";
const CREATE_REPOSITORY = "Create new repository";
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
});
@@ -53,6 +104,32 @@ function remoteBranch(name: string, remote = "origin"): Branch {
return { name, type: "remote", remote } as Branch;
}
const TOOLTIP_PILL_VALUE = "kandev";
const REPOSITORY_TOOLTIP = "Repository · ~/kandev";
function renderTooltipPill({
value = TOOLTIP_PILL_VALUE,
options = [],
tooltip = REPOSITORY_TOOLTIP,
}: {
value?: string;
options?: PillOption[];
tooltip?: string;
} = {}) {
render(
<Pill
icon={<span aria-hidden="true" />}
value={value}
placeholder="repository"
options={options}
onSelect={vi.fn()}
searchPlaceholder="Search repositories..."
emptyMessage="No repositories"
tooltip={tooltip}
/>,
);
}
describe("sortBranches", () => {
it("lifts main before master before develop before other branches", () => {
const sorted = sortBranches([
@@ -136,21 +213,10 @@ describe("Pill tooltip", () => {
);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
render(
<Pill
icon={<span aria-hidden="true" />}
value="kandev"
placeholder="repository"
options={[]}
onSelect={vi.fn()}
searchPlaceholder="Search repositories..."
emptyMessage="No repositories"
tooltip="Repository · ~/kandev"
/>,
);
renderTooltipPill();
await waitFor(() => {
expect(screen.getByTestId("tooltip-root").getAttribute("data-open")).toBe("false");
expect(screen.getByTestId(TOOLTIP_ROOT_TEST_ID).getAttribute("data-open")).toBe("false");
});
});
@@ -173,6 +239,83 @@ describe("Pill tooltip", () => {
expect(tooltipTrigger.getAttribute("tabindex")).toBe("0");
expect(tooltipTrigger.querySelector("[aria-hidden='true'] button")).not.toBeNull();
});
it("allows the first later mouse hover when the pointer left for the picker", async () => {
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
vi.stubGlobal("cancelAnimationFrame", vi.fn());
renderTooltipPill({ options: [{ value: "repo-1", label: "repo-one" }] });
const trigger = screen.getByRole("button", { name: new RegExp(TOOLTIP_PILL_VALUE, "i") });
fireEvent.click(trigger);
fireEvent.pointerLeave(trigger);
fireEvent.click(await screen.findByRole("option", { name: "repo-one" }));
fireEvent.pointerEnter(trigger);
expect(screen.getByTestId(TOOLTIP_ROOT_TEST_ID).getAttribute("data-open")).toBe("true");
});
it("keeps touch selection tooltip suppression through focus restoration", async () => {
const frameCallbacks: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
frameCallbacks.push(callback);
return frameCallbacks.length;
});
vi.stubGlobal("cancelAnimationFrame", vi.fn());
renderTooltipPill({ options: [{ value: "repo-1", label: "repo-one" }] });
frameCallbacks.shift()?.(0);
const trigger = screen.getByRole("button", { name: new RegExp(TOOLTIP_PILL_VALUE, "i") });
fireEvent.pointerEnter(trigger, { pointerType: "touch" });
fireEvent.click(trigger);
const option = await screen.findByRole("option", { name: "repo-one" });
fireEvent.pointerDown(option, { pointerType: "touch" });
fireEvent.click(option);
fireEvent.pointerLeave(trigger, { pointerType: "touch" });
frameCallbacks.shift()?.(0);
frameCallbacks.shift()?.(0);
fireEvent.blur(trigger);
fireEvent.focus(trigger);
expect(screen.getByTestId(TOOLTIP_ROOT_TEST_ID).getAttribute("data-open")).toBe("false");
fireEvent.pointerEnter(trigger, { pointerType: "mouse" });
expect(screen.getByTestId(TOOLTIP_ROOT_TEST_ID).getAttribute("data-open")).toBe("true");
});
it("wraps long repository paths inside a viewport-safe tooltip", () => {
renderTooltipPill({
value: "repository",
tooltip: `Repository · C:\\${"unbroken-path-segment".repeat(20)}`,
});
const classes = screen.getByTestId("tooltip-content").classList;
expect(classes.contains("max-w-[calc(100vw-2rem)]")).toBe(true);
expect(classes.contains("break-all")).toBe(true);
});
it("releases picker tooltip suppression when the keyboard leaves the trigger", async () => {
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
vi.stubGlobal("cancelAnimationFrame", vi.fn());
renderTooltipPill({ options: [{ value: "repo-1", label: "repo-one" }] });
const trigger = screen.getByRole("button", { name: new RegExp(TOOLTIP_PILL_VALUE, "i") });
fireEvent.click(trigger);
fireEvent.click(await screen.findByRole("option", { name: "repo-one" }));
fireEvent.blur(trigger);
fireEvent.focus(trigger);
expect(screen.getByTestId(TOOLTIP_ROOT_TEST_ID).getAttribute("data-open")).toBe("true");
});
});
describe("Pill popover", () => {
+223 -48
View File
@@ -87,11 +87,13 @@ function pillActiveClass(flat: boolean): string {
function PillCommandList({
options,
onSelect,
onPointerSelect,
setOpen,
emptyMessage,
}: {
options: PillOption[];
onSelect: (value: string) => void;
onPointerSelect: (pointerType: string) => void;
setOpen: (open: boolean) => void;
emptyMessage: string;
}) {
@@ -104,6 +106,7 @@ function PillCommandList({
key={option.value}
value={option.value}
keywords={[option.label, ...(option.keywords ?? [])]}
onPointerDown={(event) => onPointerSelect(event.pointerType)}
onSelect={() => {
onSelect(option.value);
setOpen(false);
@@ -130,6 +133,88 @@ function pillTriggerClass(disabled: boolean, flat: boolean, hasValue: boolean):
);
}
function usePillTooltipSuppression(open: boolean) {
const [suppressTooltip, setSuppressTooltip] = useState(false);
const suppressTooltipRef = useRef(false);
const pointerInsideTriggerRef = useRef(false);
const suppressionReleaseOnExitRef = useRef(true);
const suppressionReleaseArmedRef = useRef(false);
const suppressionReleaseFrameRef = useRef<number | null>(null);
const clearTooltipSuppression = useCallback(() => {
if (suppressionReleaseFrameRef.current !== null) {
cancelAnimationFrame(suppressionReleaseFrameRef.current);
suppressionReleaseFrameRef.current = null;
}
suppressionReleaseArmedRef.current = false;
suppressionReleaseOnExitRef.current = true;
suppressTooltipRef.current = false;
setSuppressTooltip(false);
}, []);
const suppressTooltipUntilLeave = useCallback(
(releaseOnExit = true) => {
if (suppressionReleaseFrameRef.current !== null) {
cancelAnimationFrame(suppressionReleaseFrameRef.current);
}
suppressionReleaseOnExitRef.current = releaseOnExit;
suppressionReleaseArmedRef.current = false;
suppressTooltipRef.current = true;
setSuppressTooltip(true);
suppressionReleaseFrameRef.current = requestAnimationFrame(() => {
suppressionReleaseFrameRef.current = requestAnimationFrame(() => {
suppressionReleaseFrameRef.current = null;
if (!pointerInsideTriggerRef.current && suppressionReleaseOnExitRef.current) {
clearTooltipSuppression();
return;
}
suppressionReleaseArmedRef.current = true;
});
});
},
[clearTooltipSuppression],
);
useEffect(
() => () => {
if (suppressionReleaseFrameRef.current !== null) {
cancelAnimationFrame(suppressionReleaseFrameRef.current);
}
},
[],
);
const handlePointerEnter = useCallback(
(event: React.PointerEvent) => {
pointerInsideTriggerRef.current = true;
if (
event.pointerType !== "touch" &&
!suppressionReleaseOnExitRef.current &&
suppressTooltipRef.current
) {
clearTooltipSuppression();
}
},
[clearTooltipSuppression],
);
const handlePointerLeave = useCallback(() => {
pointerInsideTriggerRef.current = false;
if (!open && suppressionReleaseOnExitRef.current && suppressionReleaseArmedRef.current) {
clearTooltipSuppression();
}
}, [clearTooltipSuppression, open]);
const handleBlur = useCallback(() => {
if (!open && suppressionReleaseOnExitRef.current && suppressionReleaseArmedRef.current) {
clearTooltipSuppression();
}
}, [clearTooltipSuppression, open]);
return {
suppressTooltip,
suppressTooltipRef,
suppressTooltipUntilLeave,
handlePointerEnter,
handlePointerLeave,
handleBlur,
};
}
function DisabledPillTooltip({
open,
onOpenChange,
@@ -163,6 +248,7 @@ function PillPopoverContent({
refreshLabel,
options,
onSelect,
onPointerSelect,
setOpen,
emptyMessage,
portalContainer,
@@ -175,6 +261,7 @@ function PillPopoverContent({
refreshLabel?: string;
options: PillOption[];
onSelect: (value: string) => void;
onPointerSelect: (pointerType: string) => void;
setOpen: (open: boolean) => void;
emptyMessage: string;
portalContainer: HTMLElement | null;
@@ -223,6 +310,7 @@ function PillPopoverContent({
<PillCommandList
options={options}
onSelect={onSelect}
onPointerSelect={onPointerSelect}
setOpen={setOpen}
emptyMessage={emptyMessage}
/>
@@ -231,6 +319,58 @@ function PillPopoverContent({
);
}
function PillPopover({
open,
setOpen,
triggerButton,
filter,
searchPlaceholder,
onRefresh,
refreshing,
refreshLabel,
options,
onSelect,
onPointerSelect,
emptyMessage,
portalContainer,
action,
}: {
open: boolean;
setOpen: (open: boolean) => void;
triggerButton: React.ReactElement;
filter?: PillProps["filter"];
searchPlaceholder: string;
onRefresh?: () => void;
refreshing?: boolean;
refreshLabel?: string;
options: PillOption[];
onSelect: (value: string) => void;
onPointerSelect: (pointerType: string) => void;
emptyMessage: string;
portalContainer: HTMLElement | null;
action?: PillAction;
}) {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
<PillPopoverContent
filter={filter}
searchPlaceholder={searchPlaceholder}
onRefresh={onRefresh}
refreshing={refreshing}
refreshLabel={refreshLabel}
options={options}
onSelect={onSelect}
onPointerSelect={onPointerSelect}
setOpen={setOpen}
emptyMessage={emptyMessage}
portalContainer={portalContainer}
action={action}
/>
</Popover>
);
}
function renderPillTriggerButton({
icon,
value,
@@ -240,8 +380,14 @@ function renderPillTriggerButton({
hasValue,
testId,
prefix,
onPointerEnter,
onPointerLeave,
onBlur,
}: Pick<PillProps, "icon" | "value" | "placeholder" | "disabled" | "flat" | "testId" | "prefix"> & {
hasValue: boolean;
onPointerEnter?: React.PointerEventHandler<HTMLButtonElement>;
onPointerLeave?: React.PointerEventHandler<HTMLButtonElement>;
onBlur?: React.FocusEventHandler<HTMLButtonElement>;
}): React.ReactElement {
const showPrefix = !!prefix && hasValue;
return (
@@ -250,6 +396,9 @@ function renderPillTriggerButton({
disabled={disabled}
data-testid={testId}
className={pillTriggerClass(Boolean(disabled), Boolean(flat), hasValue)}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
onBlur={onBlur}
>
{icon}
<span className="truncate max-w-[240px]">
@@ -260,6 +409,33 @@ function renderPillTriggerButton({
);
}
function usePillOpenHandlers(
setOpenState: React.Dispatch<React.SetStateAction<boolean>>,
closeTooltip: () => void,
suppressTooltipUntilLeave: (releaseOnExit?: boolean) => void,
) {
const selectionPointerTypeRef = useRef("");
const suppressForSelection = useCallback(() => {
suppressTooltipUntilLeave(selectionPointerTypeRef.current !== "touch");
}, [suppressTooltipUntilLeave]);
const setOpen = useCallback(
(next: boolean) => {
if (next) {
closeTooltip();
selectionPointerTypeRef.current = "";
} else {
suppressForSelection();
}
setOpenState(next);
},
[closeTooltip, setOpenState, suppressForSelection],
);
const recordPointerSelection = useCallback((pointerType: string) => {
selectionPointerTypeRef.current = pointerType;
}, []);
return { setOpen, suppressForSelection, recordPointerSelection };
}
/**
* Compact pill trigger that opens a popover with a search list. Auto-widths
* to its content (no `w-full`, no chevron) so multiple pills can sit on one
@@ -288,33 +464,25 @@ export function Pill({
const [open, setOpenState] = useState(false);
const { tooltipOpenState, handleTooltipOpenChange, closeTooltip } = useTooltipMountGate();
const portalContainer = useTaskCreateDialogPopoverContainer();
const [suppressTooltip, setSuppressTooltip] = useState(false);
const suppressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const setOpen = useCallback(
(next: boolean) => {
if (next) closeTooltip();
setOpenState((prev) => {
if (prev && !next) {
if (suppressTimerRef.current) clearTimeout(suppressTimerRef.current);
setSuppressTooltip(true);
suppressTimerRef.current = setTimeout(() => {
setSuppressTooltip(false);
suppressTimerRef.current = null;
}, 200);
}
return next;
});
},
[closeTooltip],
const {
suppressTooltip,
suppressTooltipRef,
suppressTooltipUntilLeave,
handlePointerEnter,
handlePointerLeave,
handleBlur,
} = usePillTooltipSuppression(open);
const { setOpen, suppressForSelection, recordPointerSelection } = usePillOpenHandlers(
setOpenState,
closeTooltip,
suppressTooltipUntilLeave,
);
useEffect(
() => () => {
if (suppressTimerRef.current) {
clearTimeout(suppressTimerRef.current);
suppressTimerRef.current = null;
}
const handlePillTooltipOpenChange = useCallback(
(next: boolean) => {
if (next && suppressTooltipRef.current) return;
handleTooltipOpenChange(next);
},
[],
[handleTooltipOpenChange],
);
const triggerButton = renderPillTriggerButton({
icon,
@@ -325,6 +493,9 @@ export function Pill({
hasValue: Boolean(value),
testId,
prefix,
onPointerEnter: tooltip ? handlePointerEnter : undefined,
onPointerLeave: tooltip ? handlePointerLeave : undefined,
onBlur: tooltip ? handleBlur : undefined,
});
// Disabled buttons swallow pointer/focus events, so the wrapper owns tooltip
@@ -341,35 +512,39 @@ export function Pill({
}
const popover = (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{tooltip ? <TooltipTrigger asChild>{triggerButton}</TooltipTrigger> : triggerButton}
</PopoverTrigger>
<PillPopoverContent
filter={filter}
searchPlaceholder={searchPlaceholder}
onRefresh={onRefresh}
refreshing={refreshing}
refreshLabel={refreshLabel}
options={options}
onSelect={onSelect}
setOpen={setOpen}
emptyMessage={emptyMessage}
portalContainer={portalContainer}
action={action}
/>
</Popover>
<PillPopover
open={open}
setOpen={setOpen}
triggerButton={
tooltip ? <TooltipTrigger asChild>{triggerButton}</TooltipTrigger> : triggerButton
}
filter={filter}
searchPlaceholder={searchPlaceholder}
onRefresh={onRefresh}
refreshing={refreshing}
refreshLabel={refreshLabel}
options={options}
onPointerSelect={recordPointerSelection}
onSelect={(selectedValue) => {
if (tooltip) suppressForSelection();
onSelect(selectedValue);
}}
emptyMessage={emptyMessage}
portalContainer={portalContainer}
action={action}
/>
);
if (!tooltip) return popover;
// Suppress the hover tooltip while the popover is open, then briefly after
// close so lingering hover cannot immediately reopen it.
const tooltipOpen = open || suppressTooltip ? false : tooltipOpenState;
// Suppress the hover tooltip while the popover is open and until the pointer
// leaves after close, so a selection cannot disclose a tooltip underneath it.
const tooltipOpen =
open || suppressTooltip || suppressTooltipRef.current ? false : tooltipOpenState;
return (
<Tooltip open={tooltipOpen} onOpenChange={handleTooltipOpenChange}>
<Tooltip open={tooltipOpen} onOpenChange={handlePillTooltipOpenChange}>
{popover}
<TooltipContent>{tooltip}</TooltipContent>
<TooltipContent className="max-w-[calc(100vw-2rem)] break-all">{tooltip}</TooltipContent>
</Tooltip>
);
}
@@ -1,7 +1,94 @@
import { test, expect } from "../../fixtures/test-base";
import { OfficeApiClient } from "../../helpers/office-api-client";
import { KanbanPage } from "../../pages/kanban-page";
import { WorkflowSettingsPage } from "../../pages/workflow-settings-page";
test.describe("Workspace settings", () => {
test("creates a Kanban workflow with template steps through workspace settings", async ({
testPage,
apiClient,
prCapture,
}) => {
const workspaceName = `Settings Kanban ${Date.now().toString(36)}`;
const taskTitle = `Kanban Bootstrap Task ${Date.now().toString(36)}`;
await testPage.goto("/settings/workspace");
await testPage.getByRole("button", { name: "Add Workspace" }).click();
await testPage.getByLabel("Workspace Name").fill(workspaceName);
await testPage.locator("form").getByRole("button", { name: "Add Workspace" }).click();
// Follow the newly rendered workspace link rather than seeding or creating
// it through the API: this covers the standard Settings creation flow.
const workspaceLink = testPage.getByRole("link").filter({
has: testPage.getByRole("heading", { name: workspaceName, exact: true }),
});
await expect(workspaceLink).toBeVisible();
const workspaceHref = await workspaceLink.getAttribute("href");
const workspaceId = new URL(workspaceHref ?? "", "http://kandev.test").pathname.split("/")[3];
expect(workspaceId).toBeTruthy();
let createdTaskId: string | undefined;
try {
await workspaceLink.click();
await expect(
testPage.getByRole("heading", { name: workspaceName, exact: true }),
).toBeVisible();
await testPage
.getByTestId("settings-scroll-container")
.getByRole("link", { name: "Workflows", exact: true })
.click();
const workflows = new WorkflowSettingsPage(testPage);
const kanbanCard = await workflows.findWorkflowCard("Kanban");
await expect(kanbanCard).toBeVisible();
for (const stepName of ["Backlog", "In Progress", "Review", "Done"]) {
await expect(workflows.stepNodeByName(kanbanCard, stepName)).toBeVisible();
}
await prCapture.screenshot("desktop-kanban-workspace-bootstrap", {
caption:
"A workspace created from Settings includes the Kanban workflow and its four ready-to-use steps.",
});
// Select the new workspace from the user-facing picker, then verify the
// bootstrap immediately makes its workflow usable by the standard New
// Task flow. Scratch mode avoids introducing a repository as test setup.
const kanban = new KanbanPage(testPage);
await kanban.goto();
await testPage.getByTestId("sidebar-workspace-trigger").click();
await testPage.getByTestId(`sidebar-workspace-item-${workspaceId}`).click();
await expect(testPage).toHaveURL(
(url) => url.pathname === "/" && url.searchParams.get("workspaceId") === workspaceId,
);
await expect(kanban.board).toBeVisible();
await kanban.createTaskButton.first().click();
const dialog = testPage.getByTestId("create-task-dialog");
await expect(dialog).toBeVisible();
await dialog.getByTestId("source-mode-scratch").click();
await dialog.getByTestId("task-title-input").fill(taskTitle);
await dialog.getByTestId("task-description-input").fill("Created without starting an agent");
await expect(dialog.getByTestId("submit-start-agent-chevron")).toBeEnabled();
const created = testPage.waitForResponse(
(response) =>
response.url().endsWith("/api/v1/tasks") && response.request().method() === "POST",
);
await dialog.getByTestId("submit-start-agent-chevron").click();
await testPage.getByTestId("submit-create-without-agent").click();
const createdTask = (await created).json() as Promise<{ id?: string }>;
createdTaskId = (await createdTask).id;
await expect(dialog).not.toBeVisible();
await expect(testPage).toHaveURL(/\/t\//);
await expect(testPage.getByRole("link", { name: taskTitle, exact: true })).toBeVisible();
} finally {
if (createdTaskId) await apiClient.deleteTask(createdTaskId);
if (workspaceId) await apiClient.deleteWorkspace(workspaceId, workspaceName);
}
});
test.describe("Workspace settings deletion", () => {
test("deletes a workspace from the settings edit page", async ({ testPage, apiClient }) => {
const suffix = Date.now().toString(36);
const workspaceName = `Settings Delete ${suffix}`;
@@ -0,0 +1,111 @@
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "../../fixtures/test-base";
import { makeGitEnv } from "../../helpers/git-helper";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
useRegularMode();
test.describe("Create task repository tooltip", () => {
test("contains a long path and waits for a deliberate re-hover after picker selection", async ({
testPage,
apiClient,
seedData,
backend,
prCapture,
}) => {
const longRepoDir = path.join(backend.tmpDir, "repos", "x".repeat(180));
fs.mkdirSync(longRepoDir, { recursive: true });
const gitEnv = makeGitEnv(backend.tmpDir);
execSync("git init -b main", { cwd: longRepoDir, env: gitEnv });
execSync('git commit --allow-empty -m "init"', { cwd: longRepoDir, env: gitEnv });
const repository = await apiClient.createRepository(seedData.workspaceId, longRepoDir, "main", {
name: "Long path repository",
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.createTaskButton.first().click();
const dialog = testPage.getByTestId("create-task-dialog");
const trigger = dialog.getByTestId("repo-chip-trigger").first();
await trigger.hover();
await trigger.click();
await testPage.getByPlaceholder("Search repositories...").fill("Long path repository");
await trigger.hover();
await testPage.keyboard.press("ArrowDown");
await testPage.keyboard.press("Enter");
await expect(dialog.getByTestId("repo-chip").first()).toHaveAttribute(
"data-repository-id",
repository.id,
);
const tooltip = testPage.getByRole("tooltip").filter({ hasText: longRepoDir });
expect(await trigger.evaluate((element) => element.matches(":hover"))).toBe(true);
await testPage.waitForTimeout(300);
await expect(tooltip).toBeHidden();
await testPage.mouse.move(0, 0);
await trigger.hover();
await expect(tooltip).toBeVisible();
const [tooltipBox, viewport] = await Promise.all([
tooltip.boundingBox(),
testPage.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })),
]);
expect(tooltipBox).not.toBeNull();
expect(tooltipBox!.x).toBeGreaterThanOrEqual(0);
expect(tooltipBox!.y).toBeGreaterThanOrEqual(0);
expect(tooltipBox!.x + tooltipBox!.width).toBeLessThanOrEqual(viewport.width);
expect(tooltipBox!.y + tooltipBox!.height).toBeLessThanOrEqual(viewport.height);
await assertNoDocumentHorizontalOverflow(testPage, "long repository tooltip");
await prCapture.screenshot("desktop-long-repository-tooltip", {
caption:
"New Task keeps a deliberately long repository path wrapped inside the desktop viewport.",
});
});
test("opens on the first later mouse hover after picker selection", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repositoryDir = path.join(backend.tmpDir, "repos", "mouse-tooltip-repository");
fs.mkdirSync(repositoryDir, { recursive: true });
const gitEnv = makeGitEnv(backend.tmpDir);
execSync("git init -b main", { cwd: repositoryDir, env: gitEnv });
execSync('git commit --allow-empty -m "init"', { cwd: repositoryDir, env: gitEnv });
const repository = await apiClient.createRepository(
seedData.workspaceId,
repositoryDir,
"main",
{
name: "Mouse tooltip repository",
},
);
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.createTaskButton.first().click();
const dialog = testPage.getByTestId("create-task-dialog");
const trigger = dialog.getByTestId("repo-chip-trigger").first();
await trigger.hover();
await trigger.click();
await testPage.getByPlaceholder("Search repositories...").fill("Mouse tooltip repository");
await testPage.getByRole("option", { name: "Mouse tooltip repository" }).click();
await expect(dialog.getByTestId("repo-chip").first()).toHaveAttribute(
"data-repository-id",
repository.id,
);
await testPage.waitForTimeout(300);
await testPage.mouse.move(0, 0);
await trigger.hover();
await expect(testPage.getByRole("tooltip").filter({ hasText: repositoryDir })).toBeVisible();
});
});
@@ -1,11 +1,15 @@
import { test, expect } from "../../fixtures/test-base";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import { useRegularMode } from "../../helpers/regular-mode";
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
useRegularMode();
test.describe("Create task workspace repository picker on mobile", () => {
test("marks another row's repository while keeping it selectable", async ({ testPage }) => {
test("marks another row's repository while keeping it selectable", async ({
testPage,
prCapture,
}) => {
const mobile = new MobileKanbanPage(testPage);
await mobile.goto();
await mobile.mobileFab.click();
@@ -17,12 +21,19 @@ test.describe("Create task workspace repository picker on mobile", () => {
await dialog.getByTestId("add-repository").click();
await expect(repositoryChips).toHaveCount(2);
await repositoryChips.nth(1).click();
await repositoryChips.nth(1).tap();
const selectedElsewhere = testPage.getByRole("option", { name: /^E2E Repo/ });
await expect(selectedElsewhere).toBeVisible();
await expect(selectedElsewhere.getByTestId("already-added-repository-marker")).toBeVisible();
await selectedElsewhere.click();
await selectedElsewhere.tap();
await expect(repositoryChips.nth(1)).toContainText("E2E Repo");
await testPage.waitForTimeout(300);
await expect(testPage.getByRole("tooltip")).toBeHidden();
await assertNoDocumentHorizontalOverflow(testPage, "repository picker selection");
await prCapture.screenshot("mobile-repository-chip-selection", {
caption:
"After mobile repository selection, the chip stays contained and no tooltip is shown.",
});
});
});
@@ -0,0 +1,94 @@
---
spec: docs/specs/workspaces/creation.md
created: 2026-07-23
status: implemented
---
# Implementation Plan: Creation flow reliability
## Overview
Fix the task-create repository tooltip regression independently from the Kanban workspace bootstrap
behavior, then verify both through their existing user-facing creation flows. The backend change
keeps Office onboarding separate while the frontend change preserves the current desktop and mobile
dialog composition.
Related requirement: `docs/specs/tasks/multi-branch/spec.md` task-creation scenarios.
## Backend
### Kanban workspace bootstrap
- Extend `service.CreateWorkspaceRequest` with an internal creation-mode signal used only by the
standard HTTP and WebSocket Kanban handlers.
- When that signal is set, persist the workspace, a `Kanban` workflow from template `simple`, and
its template-derived steps in one repository transaction. Return an error without publishing
either resource if any part of the transaction fails.
- Set the signal in `internal/task/handlers/workspace_handlers.go`; leave
`internal/backendapp/adapters_office.go` unchanged so Office onboarding does not opt in.
- Preserve the existing workspace response shape and publish the existing workspace/workflow events.
## Frontend
### Repository chip tooltip
- In `components/task-create-dialog-pill.tsx`, replace the fixed post-popover suppression timer with
suppression that lasts until the pointer leaves the trigger.
- Make repository-path tooltip content wrap unbroken paths and cap its width to the viewport.
- Keep the existing dialog, searchable popover, keyboard focus, and touch behavior unchanged.
### Mobile design contract
- Desktop outcome and mobile entry point remain the existing New Task dialog and repository chips.
- Nearest shipped mobile exemplar:
`e2e/tests/task/mobile-create-task-repository-selection.spec.ts`; reuse its same dialog/pill flow.
- Presentation remains the existing dialog and touch-usable picker popover because this fix changes
disclosure timing and path containment, not navigation, hierarchy, scrolling, or primary action.
- Shared `Pill` state owns the behavior on all viewports. No mobile-only state or persisted fallback
is introduced.
- The dialog remains the scroll owner; tooltip width is viewport-safe and no document horizontal
overflow is introduced.
## Tests
- **Post-selection suppression:** update `components/task-create-dialog-pill.test.tsx` to prove a
closed picker ignores tooltip-open requests until pointer leave, then allows a later deliberate
hover.
- **Long path containment:** assert the repository tooltip receives wrapping and viewport-width
classes.
- **Kanban workspace bootstrap:** add a focused backend handler/integration regression proving a
standard workspace creation produces one `Kanban` workflow with template-derived steps.
- **Office exclusion:** add or extend an adapter/onboarding test proving the direct Office creation
path does not opt into Kanban bootstrap.
## E2E Tests
- Add a desktop fine-pointer regression that renders the real Radix tooltip for a long unbroken
repository path, proves post-selection suppression through a deliberate leave/re-hover, and checks
its bounding box against the viewport.
- Extend `apps/web/e2e/tests/task/mobile-create-task-repository-selection.spec.ts` with the
post-selection no-tooltip and viewport containment assertions.
- Add or extend the workspace settings creation flow to create a workspace through the UI, then
verify its workflow is visible and the task-create flow is immediately usable.
## Implementation Waves
Wave 1 (parallel):
- [x] [task-01-repository-tooltip](task-01-repository-tooltip.md) — implementer, balanced tier
- [x] [task-02-kanban-workspace-bootstrap](task-02-kanban-workspace-bootstrap.md) — implementer,
balanced tier
Wave 2:
- Integrate, run focused E2E, simplify, commit, and run delegated change-aware verification.
## Verification
- `cd apps && pnpm --filter @kandev/web test -- --run components/task-create-dialog-pill.test.tsx`
- `cd apps/backend && go test -run 'Test.*CreateWorkspace.*Kanban|Test.*Office.*Workspace' ./internal/task/handlers ./internal/backendapp`
- `make build-backend build-web`
- `cd apps/web && pnpm e2e --project=chromium tests/task/create-task-repository-tooltip.spec.ts`
- `cd apps/web && pnpm e2e -- tests/task/mobile-create-task-repository-selection.spec.ts`
- Focused workspace-creation Playwright spec selected during implementation from the nearest existing
settings coverage.
@@ -0,0 +1,55 @@
---
id: "01-repository-tooltip"
title: "Repository tooltip containment and disclosure"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/tasks/multi-branch/spec.md"
---
# Task 01: Repository tooltip containment and disclosure
## Acceptance
- Long unbroken repository paths wrap inside a viewport-safe tooltip without covering neighboring
repository controls.
- Closing a repository picker suppresses its tooltip until the pointer leaves; a later deliberate
hover can open it.
- Existing keyboard-focus, popover selection, and mobile repository-selection behavior remain
usable.
## Verification
- `cd apps && pnpm --filter @kandev/web test -- --run components/task-create-dialog-pill.test.tsx`
- `make build-web`
- `cd apps/web && pnpm e2e -- tests/task/mobile-create-task-repository-selection.spec.ts`
## Files likely touched
- `apps/web/components/task-create-dialog-pill.tsx`
- `apps/web/components/task-create-dialog-pill.test.tsx`
- `apps/web/components/task-create-dialog-workspace-repo-chips.tsx`
- `apps/web/e2e/tests/task/mobile-create-task-repository-selection.spec.ts`
## Dependencies
None.
## Inputs
- `docs/specs/tasks/multi-branch/spec.md` task-creation scenarios.
- `docs/plans/creation-flow-reliability/plan.md` frontend and mobile design sections.
- Existing `useTooltipMountGate` behavior and Radix tooltip guidance in `apps/web/AGENTS.md`.
## Output contract
Return a compact handoff capsule with intent/acceptance, base/head SHA, changed files and entry
points, risk tags, exact commands/results, uncertainties, and this task status set to `done`.
## Completion
- Tooltip content wraps long unbroken paths inside a viewport-width cap.
- Post-picker suppression is armed after the close settles, so transient focus/pointer events cannot
reveal the tooltip; a later pointer leave or keyboard blur releases it.
- Unit, mobile touch, and real desktop Radix coverage exercise the disclosure and containment paths.
@@ -0,0 +1,57 @@
---
id: "02-kanban-workspace-bootstrap"
title: "Kanban workspace workflow bootstrap"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/workspaces/creation.md"
---
# Task 02: Kanban workspace workflow bootstrap
## Acceptance
- Standard HTTP and WebSocket workspace creation produce one visible `Kanban` workflow from the
built-in `simple` template with usable steps.
- Standard creation persists the workspace, Kanban workflow, and template-derived steps in one
transaction; cancellation or any persistence failure leaves none of those rows visible.
- A workflow bootstrap failure makes the standard creation request fail.
- Office onboarding's direct workspace creation path does not implicitly add a Kanban workflow.
## Verification
- `cd apps/backend && go test -run 'Test.*CreateWorkspace.*Kanban|Test.*Office.*Workspace' ./internal/task/handlers ./internal/backendapp`
- Run any directly affected backend integration package tests identified while updating fixtures.
## Files likely touched
- `apps/backend/internal/task/service/service_requests.go`
- `apps/backend/internal/task/service/service_resources.go`
- `apps/backend/internal/task/handlers/workspace_handlers.go`
- Focused `*_test.go` files beside the changed handler/service or in backend integration tests.
## Dependencies
None.
## Inputs
- `docs/specs/workspaces/creation.md`.
- `docs/plans/creation-flow-reliability/plan.md` backend section.
- Built-in template `apps/backend/config/workflows/kanban.yml`.
- Office adapter `apps/backend/internal/backendapp/adapters_office.go`.
## Output contract
Return a compact handoff capsule with intent/acceptance, base/head SHA, changed files and entry
points, risk tags, exact commands/results, uncertainties, and this task status set to `done`.
## Completion
- Standard HTTP and WebSocket creation opt into the built-in `simple` Kanban workflow bootstrap.
- Bootstrap is strict and atomic: workflow or template-step creation errors, including request
cancellation, roll back the workspace, workflow, and any partial steps together. No compensating
cleanup path is used because no bootstrap row becomes visible before commit.
- Successful creation publishes the workspace event before the child workflow event.
- Office's direct service adapter remains opt-out and is covered by regression tests.
+6 -3
View File
@@ -23,15 +23,18 @@ Workflow position and runtime state are different. Moving a card changes its wor
## Prepare a workspace
A new workspace created by a user does not automatically receive a workflow.
A new workspace created from **Settings → Workspaces** automatically receives a **Kanban** workflow
with the built-in Kanban steps, so it can accept tasks immediately.
1. Open **Settings → Workspaces** and select **Add Workspace**.
2. Enter the required workspace name.
3. Open the workspace's **Repositories** page and add existing local repositories the workspace needs. You can also initialize a new empty repository while creating a task. Remote URLs are not registered on this page; enter them through **New Task → Remote**.
4. Open its **Workflows** page and create, import, or synchronize a workflow.
4. Open its **Workflows** page to review the default **Kanban** workflow. Create, import, or synchronize another workflow when the workspace needs a different process.
5. On **Workspace Settings**, optionally choose a **Default Executor** and **Default Agent Profile**. Both default to **No default** unless configured.
The initial database bootstrap can include a **Default Workspace** and a simple development workflow. Do not assume a later workspace inherits them.
The initial database bootstrap can include a **Default Workspace** and a **Development** workflow.
Later user-created workspaces receive **Kanban** instead; they do not inherit other workflows or
settings from the default workspace.
## Create a task
+1
View File
@@ -94,6 +94,7 @@ Per-workspace credentials and triage triggers for external services.
| Spec | Status |
|---|---|
| [creation](workspaces/creation.md) | building |
| [deletion](workspaces/deletion.md) | shipped |
| [local-repositories](workspaces/local-repositories.md) | shipped |
+4 -1
View File
@@ -51,6 +51,7 @@ Workarounds (sibling tasks, manually managing two worktrees) lost shared context
- Repo chips in chat-message renderers now key on `(repository_id, checkout_branch)` so multi-branch tasks render distinct chips instead of collapsing.
- In the task-creation dialog, every workspace, discovered-on-disk, and remote provider repository picker shows an accent-colored check (with an accessible `Already added` label) when another repository row already selects that repository. The current row does not mark its own selection.
- Marked options remain selectable so users can intentionally create a multi-branch task from one repository. Removing or changing the other row removes the marker immediately.
- Repository-chip tooltips keep long local paths inside a viewport-safe, wrapping surface. Closing a repository picker does not reveal its tooltip until the pointer leaves and deliberately hovers the chip again.
- Review surfaces expose one linked pull request at a time when a task has multiple PRs. A task-scoped selector defaults to the primary (oldest) PR, remembers an in-session override, and falls back to the primary PR when that override disappears.
- Selecting a PR changes the remote PR diff contribution while preserving the existing source precedence: uncommitted worktree changes, then cumulative committed changes, then the selected PR. PR-only views and PR timeline rows resolve the exact PR rather than the task primary.
- The selector is available on desktop, phone, and coarse-pointer tablet. Phone uses a touch-sized bottom-menu treatment inside the existing Review surface; switching keeps Review open and exposes selected-PR loading, empty, and retry states.
@@ -62,6 +63,8 @@ Workarounds (sibling tasks, manually managing two worktrees) lost shared context
- **GIVEN** a task-creation Remote row selects a provider-backed repository, **WHEN** the user opens another Remote repository selector, **THEN** the same provider repository remains selectable and is visibly marked by the same compact accent-colored check.
- **GIVEN** a repository is marked because another row selects it, **WHEN** the user changes or removes that other row, **THEN** the marker disappears from the open or next-opened selector.
- **GIVEN** a row already selects a repository and no other row selects it, **WHEN** the user reopens that row's selector, **THEN** its current repository is not marked as a duplicate.
- **GIVEN** a selected repository has a long unbroken local path, **WHEN** its tooltip opens, **THEN** the tooltip wraps the path and remains within the viewport instead of covering adjacent repository controls.
- **GIVEN** a user selects a repository from a repository-chip picker, **WHEN** the picker closes while the pointer remains over the chip, **THEN** no repository tooltip opens until the pointer leaves and deliberately hovers the chip again.
## Non-goals
@@ -86,7 +89,7 @@ Workarounds (sibling tasks, manually managing two worktrees) lost shared context
- `TestAddBranchToTask_HappyPath` — second branch appended after the fact lands as a new row.
- `TestAddBranchToTask_RejectsDuplicate` — re-adding the same `(repo, branch)` errors.
- `TestLaunchPreparedSession_MultiBranch_ReusesWorktreeIDsByBranchSlug` — a follow-on session for the same task reuses each existing branch worktree instead of preparing a new task directory.
- Task-creation component and mobile E2E coverage prove the accessible, compact selected-repository marker for workspace/on-disk and Remote provider selectors while preserving option selection.
- Task-creation component and mobile E2E coverage prove the accessible, compact selected-repository marker for workspace/on-disk and Remote provider selectors while preserving option selection, viewport-safe long-path tooltips, and post-selection tooltip suppression.
- Web unit tests prove selected-PR default, override, task isolation, and removed-PR fallback behavior.
- Desktop and mobile Playwright tests prove a two-PR task can switch Review from the primary PR to a sibling PR without stale files, overflow, or closing the surface.
+70
View File
@@ -0,0 +1,70 @@
---
status: building
created: 2026-07-23
owner: Kandev
---
# Kanban workspace creation
## Why
A newly created Kanban workspace can currently contain no workflows, which leaves the task-create
flow without a destination workflow or start step. Users should be able to create a task immediately
after creating a Kanban workspace.
## What
- Creating a Kanban workspace also creates one visible workflow named `Kanban`.
- The workflow uses the built-in Kanban template and includes its configured steps and automation.
- The workspace response remains the existing workspace response; clients discover the created
workflow through the existing workflow list and real-time update surfaces.
- Office onboarding remains a separate creation path and continues to materialize its Office
workflows without receiving an implicit Kanban workflow from this behavior.
## Data model
The feature reuses the existing `workspaces`, `workflows`, and `workflow_steps` entities. The
created workflow belongs to the new workspace through `workflows.workspace_id`, references the
built-in `simple` workflow template, and owns the template-derived workflow steps.
## API surface
The existing Kanban workspace creation contracts retain their request and workspace response shapes:
- `POST /api/v1/workspaces`
- WebSocket `workspace.create`
Successful creation makes the new workflow available through the existing workflow list contracts.
Office onboarding uses its existing service-level workspace creation path and is excluded.
## Failure modes
- If the workspace row cannot be created, creation fails as it does today.
- The workspace, default Kanban workflow, and template-derived steps are committed in one
transaction. If any insert fails or the request is canceled, the transaction rolls back and none
of those rows become visible.
## Persistence guarantees
The default workflow and its steps are stored with the workspace and survive process restarts.
This is creation-time behavior only; existing empty workspaces are not backfilled.
## Scenarios
- **GIVEN** a user creates a Kanban workspace, **WHEN** creation succeeds, **THEN** listing workflows
for that workspace returns one visible `Kanban` workflow created from the built-in Kanban template.
- **GIVEN** the newly created Kanban workflow, **WHEN** its steps are listed, **THEN** the
template-derived start step and remaining Kanban steps are available so a task can be created
immediately.
- **GIVEN** Office onboarding creates an Office workspace, **WHEN** onboarding materializes its
workflows, **THEN** no extra implicit Kanban workflow is added by Kanban workspace creation.
- **GIVEN** Kanban bootstrap fails or the request is canceled before commit, **WHEN** persistence
rolls back, **THEN** no workspace, workflow, or partial step from that attempt is visible.
- **GIVEN** an existing empty workspace from an earlier version, **WHEN** Kandev starts after this
change, **THEN** the workspace remains unchanged.
## Out of scope
- Backfilling workflows into existing empty workspaces.
- Letting users choose a different default template during workspace creation.
- Changing Office onboarding or Office workflow defaults.