From 32b21c8b22ea0b7283ee68d56b5a36d41201ce07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 13 Sep 2026 16:59:54 +0100 Subject: [PATCH] feat: open canvas setup dialog directly (#3587) --- apps/backend/config/prompts/create-canvas.md | 24 + .../create_canvas_prompt_launch_test.go | 152 +++++ .../prompts/store/create_canvas_test.go | 114 ++++ apps/backend/internal/prompts/store/sqlite.go | 6 +- .../internal/prompts/store/sqlite_test.go | 17 +- .../app-sidebar/app-sidebar-section.tsx | 10 + .../sections/canvases-section.test.tsx | 43 +- .../app-sidebar/sections/canvases-section.tsx | 107 +++- .../canvas-task-create-launcher.test.tsx | 106 +++- .../canvas/canvas-task-create-launcher.tsx | 102 +++- .../canvas/canvas-task-prompt.test.ts | 37 +- .../components/canvas/canvas-task-prompt.ts | 9 + .../components/task-create-dialog-footer.tsx | 1 + .../task-create-dialog-form-body.tsx | 3 + .../task-create-dialog-selectors.test.tsx | 48 +- .../task-create-dialog-selectors.tsx | 177 ++++-- apps/web/components/task-create-dialog.tsx | 8 +- .../task-prompt-reference-editor.test.tsx | 228 ++++++++ .../task-prompt-reference-editor.tsx | 544 ++++++++++++++++++ .../components/task-prompt-reference-node.tsx | 89 +++ .../components/task/chat/rich-text-input.tsx | 2 +- apps/web/e2e/pages/task-description-editor.ts | 16 + apps/web/e2e/scripts/kubernetes-pins.test.ts | 6 + .../tests/canvas/mobile-plugin-canvas.spec.ts | 118 +++- .../e2e/tests/canvas/plugin-canvas.spec.ts | 95 ++- .../plugins/bitbucket-plugin-contract.spec.ts | 11 +- .../tests/plugins/composer-actions.spec.ts | 5 +- .../plugins/mobile-composer-actions.spec.ts | 5 +- apps/web/e2e/tests/task/create-task.spec.ts | 5 +- .../web/e2e/tests/task/enhance-prompt.spec.ts | 34 +- .../mobile-create-task-launch-preview.spec.ts | 3 +- .../task/mobile-task-create-escape.spec.ts | 5 +- .../mobile-task-create-prompt-chips.spec.ts | 137 +++++ ...task-create-prompt-autocomplete-qa.spec.ts | 143 +++-- .../task-create-prompt-autocomplete.spec.ts | 35 +- apps/web/hooks/use-inline-mention.test.ts | 25 +- apps/web/hooks/use-inline-mention.ts | 178 ++++-- .../hooks/use-task-create-prompt-mention.ts | 37 +- .../lib/prompts/task-prompt-document.test.ts | 35 ++ apps/web/lib/prompts/task-prompt-document.ts | 103 ++++ apps/web/src/locales/en/canvases.json | 2 +- apps/web/src/locales/pseudo/canvases.json | 2 +- apps/web/src/locales/pt-pt/canvases.json | 2 +- apps/web/src/locales/zh-cn/canvases.json | 2 +- apps/web/src/locales/zh-hk/canvases.json | 2 +- apps/web/src/locales/zh-tw/canvases.json | 2 +- docs/plans/canvas-direct-creation/plan.md | 236 ++++++++ .../task-01-canvas-saved-prompt.md | 107 ++++ .../task-02-direct-canvas-dialog.md | 174 ++++++ .../mcp-discovery-canvas-prompts/plan.md | 7 + .../task-02-canvas-creation-preset.md | 4 + .../plan.md | 7 + .../task-02-guided-canvas-task-launch.md | 5 + docs/plans/prompt-alias-rendering/plan.md | 4 + docs/plans/task-create-prompt-chips/plan.md | 214 +++++++ .../task-01-reference-editor.md | 114 ++++ .../task-02-create-integration.md | 142 +++++ docs/public/canvases.md | 21 + docs/public/developer-tools.md | 13 + .../requirements/agent-authored-web-apps.md | 40 +- .../system-design/agent-authored-web-apps.md | 36 +- .../guided-canvas-task-launch.md | 178 ++++++ .../ui/requirements/prompt-alias-rendering.md | 41 ++ .../system-design/prompt-alias-rendering.md | 104 ++++ 64 files changed, 3924 insertions(+), 358 deletions(-) create mode 100644 apps/backend/config/prompts/create-canvas.md create mode 100644 apps/backend/internal/orchestrator/create_canvas_prompt_launch_test.go create mode 100644 apps/backend/internal/prompts/store/create_canvas_test.go create mode 100644 apps/web/components/canvas/canvas-task-prompt.ts create mode 100644 apps/web/components/task-prompt-reference-editor.test.tsx create mode 100644 apps/web/components/task-prompt-reference-editor.tsx create mode 100644 apps/web/components/task-prompt-reference-node.tsx create mode 100644 apps/web/e2e/pages/task-description-editor.ts create mode 100644 apps/web/e2e/tests/task/mobile-task-create-prompt-chips.spec.ts create mode 100644 apps/web/lib/prompts/task-prompt-document.test.ts create mode 100644 apps/web/lib/prompts/task-prompt-document.ts create mode 100644 docs/plans/canvas-direct-creation/plan.md create mode 100644 docs/plans/canvas-direct-creation/task-01-canvas-saved-prompt.md create mode 100644 docs/plans/canvas-direct-creation/task-02-direct-canvas-dialog.md create mode 100644 docs/plans/task-create-prompt-chips/plan.md create mode 100644 docs/plans/task-create-prompt-chips/task-01-reference-editor.md create mode 100644 docs/plans/task-create-prompt-chips/task-02-create-integration.md create mode 100644 docs/specs/canvases/system-design/guided-canvas-task-launch.md diff --git a/apps/backend/config/prompts/create-canvas.md b/apps/backend/config/prompts/create-canvas.md new file mode 100644 index 0000000000..0d2e092859 --- /dev/null +++ b/apps/backend/config/prompts/create-canvas.md @@ -0,0 +1,24 @@ +Create a Kandev canvas for the goal described in the user's request. + +If the application goal is missing, ask what the canvas must show or do. + +Find the Kandev canvas MCP tools before writing application files. If they are +not callable, use native tool search for `kandev canvas` or inspect the +available MCP catalog. + +Call `create_canvas_kandev` to create the draft and obtain its source directory. +Read `read_canvas_authoring_skill_kandev` once without a path. +Build inside the returned source directory and use authorized live Kandev data +for domain views. + +Call `publish_canvas_kandev` and address any validation errors. Report the +canvas identity and whether its release is active, awaits permission review, +or was unsuccessful. + +If publication is unsuccessful, report the failure and do not claim that the +canvas is published. If workspace access requires promotion, explain the user +action that is still required. + +A local build alone does not publish a canvas inside Kandev. If the tools remain +unavailable, report the limitation instead of claiming that workspace files +are a Kandev canvas. diff --git a/apps/backend/internal/orchestrator/create_canvas_prompt_launch_test.go b/apps/backend/internal/orchestrator/create_canvas_prompt_launch_test.go new file mode 100644 index 0000000000..25437d5c95 --- /dev/null +++ b/apps/backend/internal/orchestrator/create_canvas_prompt_launch_test.go @@ -0,0 +1,152 @@ +package orchestrator + +import ( + "context" + "strings" + "testing" + + "github.com/kandev/kandev/internal/orchestrator/executor" + promptservice "github.com/kandev/kandev/internal/prompts/service" + "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" + v1 "github.com/kandev/kandev/pkg/api/v1" + "github.com/stretchr/testify/require" +) + +const canvasTaskPrompt = "Build a coordinator canvas.\n\n@create-canvas" + +// @covers AC-CANVASES-AGENT-WEB-APPS-009.8, AC-CANVASES-AGENT-WEB-APPS-009.9 +func TestCreateCanvasPromptLaunch(t *testing.T) { + t.Run("new task start expands the current built-in once", func(t *testing.T) { + ctx := context.Background() + promptService := newPromptServiceForLaunchFallbackTest(t) + canvasDefinition := requireCreateCanvasDefinition(t, ctx, promptService) + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task-new", "completed-session", models.TaskSessionStateCompleted) + + taskRepo := newMockTaskRepo() + taskRepo.tasks["task-new"] = &v1.Task{ + ID: "task-new", Title: "Canvas task", Description: canvasTaskPrompt, State: v1.TaskStateInProgress, + } + var dispatchedPrompt string + agentManager := &mockAgentManager{ + launchAgentFunc: func(_ context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { + dispatchedPrompt = req.TaskDescription + return &executor.LaunchAgentResponse{AgentExecutionID: "exec-new"}, nil + }, + } + svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentManager) + svc.promptExpander = promptService + messages := &mockMessageCreator{} + svc.messageCreator = messages + + _, err := svc.StartTask(ctx, "task-new", "profile-1", "", "", "", canvasTaskPrompt, "", false, false, nil) + require.NoError(t, err) + require.Len(t, messages.userMessages, 1) + require.NotEmpty(t, dispatchedPrompt) + assertCreateCanvasExpansion(t, messages.userMessages[0].content, canvasDefinition) + assertCreateCanvasExpansion(t, dispatchedPrompt, canvasDefinition) + }) + + t.Run("prepared launch resolves the definition that exists at launch", func(t *testing.T) { + ctx := context.Background() + promptService := newPromptServiceForLaunchFallbackTest(t) + canvasPrompt := requireCreateCanvasDefinition(t, ctx, promptService) + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task-prepared", "session-prepared", models.TaskSessionStateCreated) + seedExecutorRunning(t, repo, "session-prepared", "task-prepared", "exec-prepared") + before, err := repo.ListMessages(ctx, "session-prepared") + require.NoError(t, err) + require.Empty(t, before) + + updatedDefinition := canvasPrompt + "\n\nUse the current workspace task list." + prompt, err := promptService.GetPromptByName(ctx, "create-canvas") + require.NoError(t, err) + _, err = promptService.UpdatePrompt(ctx, prompt.ID, nil, &updatedDefinition) + require.NoError(t, err) + + taskRepo := newMockTaskRepo() + taskRepo.tasks["task-prepared"] = &v1.Task{ + ID: "task-prepared", Title: "Canvas task", Description: canvasTaskPrompt, State: v1.TaskStateInProgress, + } + agentManager := &mockAgentManager{repoForExecutionLookup: repo} + svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentManager) + svc.promptExpander = promptService + messages := &mockMessageCreator{} + svc.messageCreator = messages + + _, err = svc.StartCreatedSession(ctx, "task-prepared", "session-prepared", "profile-1", canvasTaskPrompt, false, false, false, nil, nil) + require.NoError(t, err) + require.Len(t, messages.userMessages, 1) + assertCreateCanvasExpansion(t, messages.userMessages[0].content, updatedDefinition) + }) + + t.Run("workflow step retains the short request and expands once", func(t *testing.T) { + ctx := context.Background() + promptService := newPromptServiceForLaunchFallbackTest(t) + canvasDefinition := requireCreateCanvasDefinition(t, ctx, promptService) + repo := setupTestRepo(t) + seedTaskAndSessionWithStep(t, repo, "task-workflow", "session-workflow", "step-canvas") + seedExecutorRunning(t, repo, "session-workflow", "task-workflow", "exec-workflow") + stepGetter := newMockStepGetter() + stepGetter.steps["step-canvas"] = &wfmodels.WorkflowStep{ + ID: "step-canvas", WorkflowID: "wf1", Prompt: "Follow the task request:\n\n{{task_prompt}}", + } + taskRepo := newMockTaskRepo() + taskRepo.tasks["task-workflow"] = &v1.Task{ + ID: "task-workflow", Title: "Canvas task", Description: canvasTaskPrompt, State: v1.TaskStateInProgress, + } + agentManager := &mockAgentManager{repoForExecutionLookup: repo} + svc := createTestServiceWithScheduler(repo, stepGetter, taskRepo, agentManager) + svc.promptExpander = promptService + messages := &mockMessageCreator{} + svc.messageCreator = messages + + _, err := svc.StartCreatedSession(ctx, "task-workflow", "session-workflow", "profile-1", canvasTaskPrompt, false, false, false, nil, nil) + require.NoError(t, err) + require.Len(t, messages.userMessages, 1) + got := messages.userMessages[0].content + require.Contains(t, got, canvasTaskPrompt) + assertCreateCanvasExpansion(t, got, canvasDefinition) + }) + + t.Run("removed reference has no hidden expansion", func(t *testing.T) { + ctx := context.Background() + promptService := newPromptServiceForLaunchFallbackTest(t) + canvasDefinition := requireCreateCanvasDefinition(t, ctx, promptService) + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task-plain", "session-plain", models.TaskSessionStateCreated) + seedExecutorRunning(t, repo, "session-plain", "task-plain", "exec-plain") + taskRepo := newMockTaskRepo() + taskRepo.tasks["task-plain"] = &v1.Task{ + ID: "task-plain", Title: "Plain task", Description: "Build a coordinator canvas.", State: v1.TaskStateInProgress, + } + agentManager := &mockAgentManager{repoForExecutionLookup: repo} + svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentManager) + svc.promptExpander = promptService + messages := &mockMessageCreator{} + svc.messageCreator = messages + + _, err := svc.StartCreatedSession(ctx, "task-plain", "session-plain", "profile-1", "Build a coordinator canvas.", false, false, false, nil, nil) + require.NoError(t, err) + require.Len(t, messages.userMessages, 1) + got := messages.userMessages[0].content + require.NotContains(t, got, canvasDefinition) + require.NotContains(t, got, "### @create-canvas") + }) +} + +func requireCreateCanvasDefinition(t *testing.T, ctx context.Context, service *promptservice.Service) string { + t.Helper() + definition := service.ResolvePromptContent(ctx, "create-canvas", "") + require.NotEmpty(t, definition) + return definition +} + +func assertCreateCanvasExpansion(t *testing.T, prompt, definition string) { + t.Helper() + require.Contains(t, prompt, canvasTaskPrompt) + require.Contains(t, prompt, definition) + require.Equal(t, 1, strings.Count(prompt, "### @create-canvas")) + require.Equal(t, 1, strings.Count(prompt, definition)) +} diff --git a/apps/backend/internal/prompts/store/create_canvas_test.go b/apps/backend/internal/prompts/store/create_canvas_test.go new file mode 100644 index 0000000000..7d76c24edb --- /dev/null +++ b/apps/backend/internal/prompts/store/create_canvas_test.go @@ -0,0 +1,114 @@ +package store + +import ( + "context" + "strconv" + "strings" + "testing" + "time" +) + +func TestCreateCanvasBuiltin(t *testing.T) { + t.Run("seeds the complete authoring workflow", func(t *testing.T) { + repo, cleanup := createTestRepo(t) + defer cleanup() + + prompt, err := repo.GetPromptByName(context.Background(), "create-canvas") + if err != nil { + t.Fatalf("get create-canvas prompt: %v", err) + } + if prompt.ID != "builtin-create-canvas" || !prompt.Builtin { + t.Fatalf("unexpected prompt identity: %+v", prompt) + } + for _, want := range []string{ + "create_canvas_kandev", + "read_canvas_authoring_skill_kandev", + "publish_canvas_kandev", + "native tool search", + "source directory", + "authorized live Kandev data", + "awaits permission review", + "requires promotion", + "local build alone does not publish", + } { + if !strings.Contains(prompt.Content, want) { + t.Fatalf("create-canvas prompt is missing %q", want) + } + } + }) + + t.Run("preserves a user prompt with the same name", func(t *testing.T) { + db := createUnseededPromptDB(t) + now := time.Now().UTC() + if _, err := db.Exec( + `INSERT INTO custom_prompts (id, name, content, builtin, created_at, updated_at) VALUES (?, ?, ?, 0, ?, ?)`, + "user-create-canvas", "create-canvas", "user-owned prompt", now, now, + ); err != nil { + t.Fatalf("seed user prompt: %v", err) + } + + repo, err := newSQLiteRepositoryWithDB(db, db) + if err != nil { + t.Fatalf("initialize repository: %v", err) + } + got, err := repo.GetPromptByName(context.Background(), "create-canvas") + if err != nil { + t.Fatalf("get user prompt: %v", err) + } + if got.ID != "user-create-canvas" || got.Builtin || got.Content != "user-owned prompt" { + t.Fatalf("user prompt was replaced: %+v", got) + } + }) + + t.Run("preserves user edits when the database is reopened", func(t *testing.T) { + db := createUnseededPromptDB(t) + repo, err := newSQLiteRepositoryWithDB(db, db) + if err != nil { + t.Fatalf("initialize repository: %v", err) + } + prompt, err := repo.GetPromptByName(context.Background(), "create-canvas") + if err != nil { + t.Fatalf("get seeded prompt: %v", err) + } + prompt.Content = "user-edited canvas workflow" + if err := repo.UpdatePrompt(context.Background(), prompt); err != nil { + t.Fatalf("update prompt: %v", err) + } + + if _, err := newSQLiteRepositoryWithDB(db, db); err != nil { + t.Fatalf("reopen repository: %v", err) + } + got, err := repo.GetPromptByName(context.Background(), "create-canvas") + if err != nil { + t.Fatalf("get edited prompt: %v", err) + } + if got.Content != "user-edited canvas workflow" { + t.Fatalf("user edit was replaced with %q", got.Content) + } + }) + + t.Run("does not overflow the prompt cap during startup seeding", func(t *testing.T) { + db := createUnseededPromptDB(t) + now := time.Now().UTC() + for i := range maxPromptListItems { + if _, err := db.Exec( + `INSERT INTO custom_prompts (id, name, content, builtin, created_at, updated_at) VALUES (?, ?, ?, 0, ?, ?)`, + "full-"+strconv.Itoa(i), "full-"+strconv.Itoa(i), "user prompt", now, now, + ); err != nil { + t.Fatalf("fill prompt table at %d: %v", i, err) + } + } + + repo, err := newSQLiteRepositoryWithDB(db, db) + if err != nil { + t.Fatalf("initialize repository: %v", err) + } + prompts, err := repo.ListPrompts(context.Background()) + if err != nil { + t.Fatalf("list capped prompts: %v", err) + } + if len(prompts) != maxPromptListItems { + t.Fatalf("prompt count changed from the cap: got %d", len(prompts)) + } + }) +} diff --git a/apps/backend/internal/prompts/store/sqlite.go b/apps/backend/internal/prompts/store/sqlite.go index e5039075bd..f42c4a6407 100644 --- a/apps/backend/internal/prompts/store/sqlite.go +++ b/apps/backend/internal/prompts/store/sqlite.go @@ -275,9 +275,10 @@ func (r *sqliteRepository) seedBuiltinPrompts() error { for _, prompt := range r.getBuiltinPrompts() { _, err := r.db.Exec(r.db.Rebind(` INSERT INTO custom_prompts (id, name, content, builtin, created_at, updated_at) - VALUES (?, ?, ?, 1, ?, ?) + SELECT ?, ?, ?, 1, ?, ? + WHERE (SELECT COUNT(*) FROM custom_prompts) < ? ON CONFLICT DO NOTHING - `), prompt.ID, prompt.Name, prompt.Content, prompt.CreatedAt, prompt.UpdatedAt) + `), prompt.ID, prompt.Name, prompt.Content, prompt.CreatedAt, prompt.UpdatedAt, maxPromptListItems) if err != nil { return fmt.Errorf("failed to upsert built-in prompt %s: %w", prompt.ID, err) } @@ -386,6 +387,7 @@ func isLegacyCIAutoFixPrompt(content string) bool { func (r *sqliteRepository) getBuiltinPrompts() []*models.Prompt { now := time.Now().UTC() return []*models.Prompt{ + {ID: "builtin-create-canvas", Name: "create-canvas", Builtin: true, CreatedAt: now, UpdatedAt: now, Content: promptcfg.Get("create-canvas")}, {ID: "builtin-code-review", Name: "code-review", Builtin: true, CreatedAt: now, UpdatedAt: now, Content: promptcfg.Get("code-review")}, {ID: "builtin-open-pr", Name: "open-pr", Builtin: true, CreatedAt: now, UpdatedAt: now, Content: promptcfg.Get("open-pr")}, {ID: "builtin-merge-base", Name: "merge-base", Builtin: true, CreatedAt: now, UpdatedAt: now, Content: promptcfg.Get("merge-base")}, diff --git a/apps/backend/internal/prompts/store/sqlite_test.go b/apps/backend/internal/prompts/store/sqlite_test.go index f1d510eb61..1b422686ac 100644 --- a/apps/backend/internal/prompts/store/sqlite_test.go +++ b/apps/backend/internal/prompts/store/sqlite_test.go @@ -168,8 +168,8 @@ func TestSQLiteRepository_CRUD(t *testing.T) { t.Fatalf("expected custom prompt to be deleted, but it still exists") } } - if builtinCount != 6 { - t.Fatalf("expected 6 built-in prompts, got %d", builtinCount) + if builtinCount != 7 { + t.Fatalf("expected 7 built-in prompts, got %d", builtinCount) } } @@ -184,14 +184,18 @@ func TestSQLiteRepository_BuiltinPrompts(t *testing.T) { t.Fatalf("list prompts: %v", err) } - // Should include the CI auto-fix and changes walkthrough built-in prompts. + // Should include the canvas, CI auto-fix, and changes walkthrough built-in prompts. builtinCount := 0 + var createCanvasContent string var ciAutoFixContent string var changesWalkthroughContent string for _, p := range list { if p.Builtin { builtinCount++ } + if p.ID == "builtin-create-canvas" && p.Name == "create-canvas" && p.Content != "" { + createCanvasContent = p.Content + } if p.ID == "builtin-ci-auto-fix" && p.Name == "ci-auto-fix" && p.Content != "" { ciAutoFixContent = p.Content } @@ -200,8 +204,11 @@ func TestSQLiteRepository_BuiltinPrompts(t *testing.T) { } } - if builtinCount != 6 { - t.Fatalf("expected 6 built-in prompts, got %d", builtinCount) + if builtinCount != 7 { + t.Fatalf("expected 7 built-in prompts, got %d", builtinCount) + } + if createCanvasContent == "" { + t.Fatalf("expected create-canvas built-in prompt") } if ciAutoFixContent == "" { t.Fatalf("expected ci-auto-fix built-in prompt") diff --git a/apps/web/components/app-sidebar/app-sidebar-section.tsx b/apps/web/components/app-sidebar/app-sidebar-section.tsx index eeea0b98e8..90666646ab 100644 --- a/apps/web/components/app-sidebar/app-sidebar-section.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-section.tsx @@ -1,5 +1,6 @@ "use client"; +import type { RefObject } from "react"; import { IconChevronRight } from "@tabler/icons-react"; import type { Icon as TablerIcon } from "@tabler/icons-react"; import { Collapsible, CollapsibleContent } from "@kandev/ui/collapsible"; @@ -28,6 +29,8 @@ type AppSidebarSectionProps = { grow?: boolean; /** Initial expansion when the persisted section map does not yet contain this id. */ defaultExpanded?: boolean; + /** Stable focus target for dialogs opened from this section. */ + headerRef?: RefObject; }; type SectionHeaderProps = { @@ -37,6 +40,7 @@ type SectionHeaderProps = { headerActionVisibility: "expanded" | "always"; collapsedSummary?: React.ReactNode; onToggle: () => void; + headerRef?: RefObject; }; function SectionHeader({ @@ -46,12 +50,14 @@ function SectionHeader({ headerActionVisibility, collapsedSummary, onToggle, + headerRef, }: SectionHeaderProps) { const showHeaderAction = !!headerAction && (expanded || headerActionVisibility === "always"); return (
); } @@ -122,29 +157,43 @@ export function CanvasesSection({ collapsed }: { collapsed: boolean }) { const enabled = useFeature("canvases"); const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId); const workspaceId = enabled ? activeWorkspaceId : null; - const canvases = useWorkspaceCanvases(workspaceId); + const { canvases, ready } = useWorkspaceCanvasesState(workspaceId); const activeCanvases = canvases.filter(isActiveWorkspaceCanvas); + const sectionHeaderRef = useRef(null); if (!enabled || !activeWorkspaceId) return null; return ( - } - headerActionVisibility="always" - collapsedSummary={activeCanvases.length > 0 ? activeCanvases.length : undefined} - defaultExpanded={false} + - {activeCanvases.length === 0 ? ( - - ) : ( - activeCanvases.map((canvas) => ( - - )) + {({ onOpen, triggerRef }) => ( + } + headerActionVisibility="always" + collapsedSummary={activeCanvases.length > 0 ? activeCanvases.length : undefined} + defaultExpanded={false} + headerRef={sectionHeaderRef} + > + {ready && activeCanvases.length === 0 ? ( + + ) : ( + activeCanvases.map((canvas) => ( + + )) + )} + )} - + ); } diff --git a/apps/web/components/canvas/canvas-task-create-launcher.test.tsx b/apps/web/components/canvas/canvas-task-create-launcher.test.tsx index 7d932b8469..ec14b007a8 100644 --- a/apps/web/components/canvas/canvas-task-create-launcher.test.tsx +++ b/apps/web/components/canvas/canvas-task-create-launcher.test.tsx @@ -9,6 +9,8 @@ const mocks = vi.hoisted(() => ({ type DialogProps = { open: boolean; + onOpenChange: (open: boolean) => void; + focusReturnRef?: { current: HTMLElement | null }; initialValues?: { title?: string; description?: string; @@ -20,8 +22,11 @@ type DialogProps = { }; const CREATE_CANVAS_LABEL = "Create canvas"; +const SET_UP_CANVAS_LABEL = "Set up a canvas"; const CREATE_CANVAS_TITLE = "Create a canvas"; -const CREATE_CANVAS_PROMPT = "Build a canvas application"; +const CREATE_CANVAS_GOAL = + "Create a new Kandev canvas with a coordinator view that lists the existing tasks."; +const CREATE_CANVAS_PROMPT = `${CREATE_CANVAS_GOAL}\n\n@create-canvas`; vi.mock("@/hooks/domains/features/use-feature", () => ({ useFeature: () => mocks.featureEnabled, @@ -48,14 +53,77 @@ vi.mock("react-i18next", () => ({ t: (key: string) => ({ "canvases:createCanvas": CREATE_CANVAS_LABEL, + "canvases:setUpCanvas": SET_UP_CANVAS_LABEL, "canvases:createCanvasTaskTitle": CREATE_CANVAS_TITLE, - "canvases:createCanvasTaskPrompt": CREATE_CANVAS_PROMPT, + "canvases:createCanvasTaskPrompt": CREATE_CANVAS_GOAL, })[key] ?? key, }), })); import { CanvasTaskCreateLauncher } from "./canvas-task-create-launcher"; +function testOpenerFocusFallback() { + const focusReturnRef = { current: null as HTMLElement | null }; + const trigger = ({ + onOpen, + triggerRef, + }: { + onOpen: () => void; + triggerRef: { current: HTMLButtonElement | null }; + }) => ( + + ); + const { rerender } = render( + <> + + + {trigger} + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "open" })); + + expect(mocks.dialogProps?.focusReturnRef?.current).toBe( + screen.getByRole("button", { name: "open" }), + ); + + rerender( + <> + + + {() => null} + + , + ); + + expect(mocks.dialogProps?.focusReturnRef?.current).toBe( + screen.getByRole("button", { name: "fallback" }), + ); +} + beforeEach(() => { mocks.featureEnabled = true; mocks.push.mockReset(); @@ -82,6 +150,40 @@ describe("CanvasTaskCreateLauncher", () => { expect(mocks.push).toHaveBeenCalledWith("/t/task-1"); }); + it("uses a semantic sidebar setup button and the same task preset", () => { + render(); + + const setup = screen.getByTestId("sidebar-canvases-empty"); + expect(setup.tagName).toBe("BUTTON"); + expect(setup.textContent).toContain(SET_UP_CANVAS_LABEL); + expect(setup.getAttribute("href")).toBeNull(); + + fireEvent.click(setup); + + expect(mocks.dialogProps?.initialValues).toEqual({ + title: CREATE_CANVAS_TITLE, + description: CREATE_CANVAS_PROMPT, + noRepository: true, + preferLocalExecutor: true, + }); + }); + + it("prefers the opener and falls back when the sidebar opener unmounts", testOpenerFocusFallback); + + it("closes the draft when its workspace or feature availability changes", () => { + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: CREATE_CANVAS_LABEL })); + expect(mocks.dialogProps?.open).toBe(true); + + rerender(); + expect(mocks.dialogProps?.open).toBe(false); + + fireEvent.click(screen.getByRole("button", { name: CREATE_CANVAS_LABEL })); + mocks.featureEnabled = false; + rerender(); + expect(screen.queryByRole("button", { name: CREATE_CANVAS_LABEL })).toBeNull(); + }); + it("does not expose a canvas action while the feature is disabled", () => { mocks.featureEnabled = false; diff --git a/apps/web/components/canvas/canvas-task-create-launcher.tsx b/apps/web/components/canvas/canvas-task-create-launcher.tsx index 50ba3b5e4b..ffe5755526 100644 --- a/apps/web/components/canvas/canvas-task-create-launcher.tsx +++ b/apps/web/components/canvas/canvas-task-create-launcher.tsx @@ -1,6 +1,14 @@ "use client"; -import { useCallback, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, + type RefObject, +} from "react"; import { useTranslation } from "react-i18next"; import { IconPlus } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; @@ -10,20 +18,72 @@ import { useRouter } from "@/lib/routing/client-router"; import { linkToTask } from "@/lib/links"; import type { Task } from "@/lib/types/http"; import { controlSizingClassName } from "@kandev/ui/control-sizing"; +import { buildCanvasCreateTaskPrompt } from "./canvas-task-prompt"; + +export type CanvasTaskCreateLauncherPresentation = "settings" | "sidebar"; + +export type CanvasTaskCreateLauncherTriggerProps = { + onOpen: () => void; + triggerRef: RefObject; +}; export type CanvasTaskCreateLauncherProps = { workspaceId: string | null; + presentation?: CanvasTaskCreateLauncherPresentation; + focusReturnRef?: RefObject; + children?: (props: CanvasTaskCreateLauncherTriggerProps) => ReactNode; }; -export function CanvasTaskCreateLauncher({ workspaceId }: CanvasTaskCreateLauncherProps) { +export function CanvasTaskCreateLauncher({ + workspaceId, + presentation = "settings", + focusReturnRef, + children, +}: CanvasTaskCreateLauncherProps) { const { t } = useTranslation(); const router = useRouter(); const enabled = useFeature("canvases"); const [open, setOpen] = useState(false); + const [dialogWorkspaceId, setDialogWorkspaceId] = useState(null); + const triggerRef = useRef(null); + const focusReturnTargetRef = useMemo>( + () => ({ + get current() { + return triggerRef.current ?? focusReturnRef?.current ?? null; + }, + }), + [focusReturnRef], + ); + + const handleOpen = useCallback(() => { + if (!enabled || !workspaceId) return; + setDialogWorkspaceId(workspaceId); + setOpen(true); + }, [enabled, workspaceId]); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (nextOpen) { + handleOpen(); + return; + } + setOpen(false); + setDialogWorkspaceId(null); + }, + [handleOpen], + ); + + useEffect(() => { + if (!enabled || !workspaceId || (dialogWorkspaceId && dialogWorkspaceId !== workspaceId)) { + setOpen(false); + setDialogWorkspaceId(null); + } + }, [dialogWorkspaceId, enabled, workspaceId]); const handleSuccess = useCallback( (task: Task, _mode: "create" | "edit", meta?: { willNavigate?: boolean }) => { setOpen(false); + setDialogWorkspaceId(null); if (!meta?.willNavigate) router.push(linkToTask(task.id)); }, [router], @@ -31,22 +91,33 @@ export function CanvasTaskCreateLauncher({ workspaceId }: CanvasTaskCreateLaunch if (!enabled || !workspaceId) return null; - const label = t("canvases:createCanvas"); + const sidebar = presentation === "sidebar"; + const label = t(sidebar ? "canvases:setUpCanvas" : "canvases:createCanvas"); + const trigger = children ? ( + children({ onOpen: handleOpen, triggerRef }) + ) : ( + + ); return ( <> - + {trigger} diff --git a/apps/web/components/canvas/canvas-task-prompt.test.ts b/apps/web/components/canvas/canvas-task-prompt.test.ts index e43c0bc4a7..fc8f25bf4a 100644 --- a/apps/web/components/canvas/canvas-task-prompt.test.ts +++ b/apps/web/components/canvas/canvas-task-prompt.test.ts @@ -4,6 +4,7 @@ import ptCanvases from "@/src/locales/pt-pt/canvases.json"; import zhCnCanvases from "@/src/locales/zh-cn/canvases.json"; import zhHkCanvases from "@/src/locales/zh-hk/canvases.json"; import zhTwCanvases from "@/src/locales/zh-tw/canvases.json"; +import { buildCanvasCreateTaskPrompt, CANVAS_CREATE_PROMPT_REFERENCE } from "./canvas-task-prompt"; import { describe, expect, it } from "vitest"; const canvasCatalogs = { @@ -15,31 +16,31 @@ const canvasCatalogs = { pseudo: pseudoCanvases, }; -const requiredCanvasTools = [ - "create_canvas_kandev", - "read_canvas_authoring_skill_kandev", - "publish_canvas_kandev", -]; - describe("canvas creation task preset", () => { - it("keeps the authoring workflow and callable tool names in every catalog", () => { + it("keeps a short localized goal in every catalog", () => { for (const [locale, catalog] of Object.entries(canvasCatalogs)) { const prompt = catalog.createCanvasTaskPrompt; expect(prompt, locale).toMatch(/\S/); - for (const tool of requiredCanvasTools) { - expect(prompt, `${locale} is missing ${tool}`).toContain(tool); - } + expect(prompt, locale).not.toContain("create_canvas_kandev"); + expect(prompt, locale).not.toContain("read_canvas_authoring_skill_kandev"); + expect(prompt, locale).not.toContain("publish_canvas_kandev"); } }); - it("requires Kandev publication instead of treating files or a build as a release", () => { - const prompt = enCanvases.createCanvasTaskPrompt; + it("appends the exact saved-prompt reference outside localization", () => { + for (const [locale, catalog] of Object.entries(canvasCatalogs)) { + expect(buildCanvasCreateTaskPrompt(catalog.createCanvasTaskPrompt), locale).toBe( + `${catalog.createCanvasTaskPrompt}\n\n${CANVAS_CREATE_PROMPT_REFERENCE}`, + ); + } + }); - expect(prompt).toContain("Create the draft in Kandev"); - expect(prompt).toContain("Build inside the returned directory"); - expect(prompt).toContain("A local build alone does not publish a canvas inside Kandev"); - expect(prompt).toContain("If publication is unsuccessful, report the failure"); - expect(prompt).toContain("do not claim that the canvas is published"); - expect(prompt).toContain("report the limitation instead of claiming"); + it("keeps the English goal editable while leaving authoring instructions to the saved prompt", () => { + expect(enCanvases.createCanvasTaskPrompt).toBe( + "Create a new Kandev canvas with a coordinator view that lists the existing tasks.", + ); + expect(buildCanvasCreateTaskPrompt(enCanvases.createCanvasTaskPrompt)).toBe( + "Create a new Kandev canvas with a coordinator view that lists the existing tasks.\n\n@create-canvas", + ); }); }); diff --git a/apps/web/components/canvas/canvas-task-prompt.ts b/apps/web/components/canvas/canvas-task-prompt.ts new file mode 100644 index 0000000000..a6daa7f802 --- /dev/null +++ b/apps/web/components/canvas/canvas-task-prompt.ts @@ -0,0 +1,9 @@ +// i18n-exempt: exact saved-prompt reference that users can edit in task text. +export const CANVAS_CREATE_PROMPT_REFERENCE = "@create-canvas"; + +export function buildCanvasCreateTaskPrompt(goal: string): string { + const normalizedGoal = goal.trim(); + return normalizedGoal + ? `${normalizedGoal}\n\n${CANVAS_CREATE_PROMPT_REFERENCE}` + : CANVAS_CREATE_PROMPT_REFERENCE; +} diff --git a/apps/web/components/task-create-dialog-footer.tsx b/apps/web/components/task-create-dialog-footer.tsx index ec6f834fa4..15023cb407 100644 --- a/apps/web/components/task-create-dialog-footer.tsx +++ b/apps/web/components/task-create-dialog-footer.tsx @@ -444,6 +444,7 @@ export const TaskCreateDialogFooter = memo(function TaskCreateDialogFooter( onClick={onCancel} disabled={isCreatingSession || isCreatingTask} className="w-full border-0 cursor-pointer sm:w-auto sm:border" + data-testid="submit-cancel" > {t("common:cancel")} diff --git a/apps/web/components/task-create-dialog-form-body.tsx b/apps/web/components/task-create-dialog-form-body.tsx index 055b20f72a..cdd6bc2706 100644 --- a/apps/web/components/task-create-dialog-form-body.tsx +++ b/apps/web/components/task-create-dialog-form-body.tsx @@ -490,6 +490,7 @@ export const WorkflowSection = memo(function WorkflowSection(workflowProps: Work export type DialogPromptSectionProps = { isSessionMode: boolean; + promptReferencesEnabled?: boolean; isTaskStarted: boolean; initialDescription: string; fs: DialogFormState; @@ -531,6 +532,7 @@ function importBindings( export function DialogPromptSection({ isSessionMode, + promptReferencesEnabled = false, isTaskStarted, initialDescription, fs, @@ -556,6 +558,7 @@ export function DialogPromptSection({ ({ // Inert mention popover — the real hook installs a `keydown` listener that // drains React's event queue across re-renders and adds noise to assertions. -vi.mock("@/hooks/use-task-create-prompt-mention", () => ({ - useTaskCreatePromptMention: () => ({ +vi.mock("@/hooks/use-task-create-prompt-mention", () => { + const useMention = ({ onChange }: { onChange?: (value: string) => void } = {}) => ({ isOpen: false, isLoading: false, position: null, items: [], query: "", selectedIndex: 0, - handleChange: (_: string) => {}, + handleChange: (value: string) => onChange?.(value), handleKeyDown: mentionMocks.handleKeyDown, handleSelect: () => {}, closeMenu: () => {}, setSelectedIndex: () => {}, - }), -})); + }); + return { + useTaskCreatePromptMention: useMention, + useTaskCreatePromptMentionForInput: useMention, + }; +}); afterEach(() => { cleanup(); @@ -80,10 +85,20 @@ function Wrapper({ children }: { children: ReactNode }) { ); } +function RichPromptWrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + function renderTaskFormInputs( initial: string, strict = false, launchPreview: TaskCreateLaunchPreview | null = null, + promptReferencesEnabled = false, + onComposerSubmit?: () => boolean | Promise, ) { const ref = createRef(); const form = ( @@ -91,13 +106,16 @@ function renderTaskFormInputs( isSessionMode={false} autoFocus={false} initialDescription={initial} + promptReferencesEnabled={promptReferencesEnabled} + onComposerSubmit={onComposerSubmit} onDescriptionChange={() => {}} onKeyDown={() => {}} descriptionValueRef={ref} launchPreview={launchPreview} /> ); - const utils = render(strict ? {form} : form, { wrapper: Wrapper }); + const wrapper = promptReferencesEnabled ? RichPromptWrapper : Wrapper; + const utils = render(strict ? {form} : form, { wrapper }); const textarea = screen.getByTestId(DESCRIPTION_INPUT_TEST_ID) as HTMLTextAreaElement; return { ...utils, textarea, ref }; } @@ -324,6 +342,24 @@ describe("TaskFormInputs plugin composer chained calls", () => { expect(textarea.value).toBe("first second"); }); + + it("supports rich prompt references for literal plugin insertion and submit", async () => { + const onComposerSubmit = vi.fn(() => true); + const { ref } = renderTaskFormInputs("", false, null, true, onComposerSubmit); + const composer = lastPluginSlotProps().composer; + const first = "

hello

&\n first"; + let result: Awaited> | undefined; + + await act(async () => { + composer.insertText(first); + composer.insertText("tail"); + result = await composer.submit(); + }); + + expect(result).toEqual({ status: "submitted" }); + expect(ref.current?.getValue()).toBe(`${first} tail`); + expect(onComposerSubmit).toHaveBeenCalledTimes(1); + }); }); describe("TaskFormInputs plugin composer action — at-cursor splice", () => { diff --git a/apps/web/components/task-create-dialog-selectors.tsx b/apps/web/components/task-create-dialog-selectors.tsx index 5352c21f1a..12e06af96a 100644 --- a/apps/web/components/task-create-dialog-selectors.tsx +++ b/apps/web/components/task-create-dialog-selectors.tsx @@ -27,6 +27,8 @@ import { ContextZone } from "@/components/task/chat/context-items/context-zone"; import { MentionMenu } from "@/components/task/chat/mention-menu"; import type { ContextItem, ImageContextItem, FileAttachmentContextItem } from "@/lib/types/context"; import type { TaskFormInputsHandle } from "@/components/task-create-dialog-types"; +import type { RichTextInputHandle } from "@/components/task/chat/rich-text-input"; +import { TaskPromptReferenceEditor } from "@/components/task-prompt-reference-editor"; import type { TaskCreateLaunchPreview } from "@/components/task-create-dialog-launch-preview"; import { composeLaunchPreviewPrompt } from "@/components/task-create-dialog-launch-preview"; import { @@ -277,6 +279,7 @@ export const InlineTaskName = memo(function InlineTaskName({ // Memoized description input to prevent re-rendering the entire dialog on every keystroke type TaskFormInputsProps = { isSessionMode: boolean; + promptReferencesEnabled?: boolean; taskId?: string | null; workspaceId?: string | null; autoFocus?: boolean; @@ -449,7 +452,7 @@ function useAttachmentHandlers( setIsDragging: (v: boolean) => void, ) { const handlePaste = useCallback( - (e: React.ClipboardEvent) => { + (e: React.ClipboardEvent) => { if (disabled) return; const { files, issue } = readClipboardAttachments(e.clipboardData); if (files.length > 0 || issue) { @@ -731,7 +734,7 @@ function useCreationComposerPluginActions(args: { disabled: boolean; description: string; descriptionRef: React.RefObject; - textareaRef: React.RefObject; + focusComposer: () => boolean; insertAtCursor: (text: string) => void; submit?: () => boolean | Promise; }) { @@ -743,11 +746,7 @@ function useCreationComposerPluginActions(args: { args.insertAtCursor(text); return true; }, - focus: () => { - if (!args.textareaRef.current) return false; - args.textareaRef.current.focus(); - return true; - }, + focus: args.focusComposer, // Gate on the synchronous ref for the same reason the chat composer // reads its editor: insert-then-submit in one callback happens before // React re-renders with the new description. @@ -842,11 +841,86 @@ function DraggingOverlay({ isDragging }: { isDragging: boolean }) { ); } +type TaskDescriptionInputProps = { + isLaunchPromptPreview: boolean; + launchPromptPreview: string; + promptReferencesEnabled: boolean; + referenceInputRef: React.RefObject; + description: string; + onDescriptionChange: (value: string) => void; + descriptionPlaceholder: string; + isSessionMode: boolean; + autoFocus?: boolean; + disabled?: boolean; + onKeyDown: TaskFormInputsProps["onKeyDown"]; + onPaste: React.ClipboardEventHandler; + handleChange: React.ChangeEventHandler; + handleKeyDownCapture: React.KeyboardEventHandler; + handleKeyDown: React.KeyboardEventHandler; + textareaRef: React.RefObject; +}; + +function TaskDescriptionInput({ + isLaunchPromptPreview, + launchPromptPreview, + promptReferencesEnabled, + referenceInputRef, + description, + onDescriptionChange, + descriptionPlaceholder, + isSessionMode, + autoFocus, + disabled, + onKeyDown, + onPaste, + handleChange, + handleKeyDownCapture, + handleKeyDown, + textareaRef, +}: TaskDescriptionInputProps) { + if (isLaunchPromptPreview) { + return ; + } + + if (promptReferencesEnabled) { + return ( + + ); + } + + return ( +