From a2b4fc0c6d18420bdbe993ffd02b97a94ebcd6ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Fri, 22 May 2026 15:36:46 +0100 Subject: [PATCH] fix(web): keep sidebar 3-dot menu from overlapping the subtask toggle (#1012) --- apps/backend/cmd/mock-agent/handler.go | 47 +++++++++++++++++++ apps/backend/cmd/mock-agent/main.go | 1 + .../backend/cmd/mock-agent/mock_agent_test.go | 31 ++++++++++++ apps/web/components/task/task-item.tsx | 24 ++++------ 4 files changed, 88 insertions(+), 15 deletions(-) diff --git a/apps/backend/cmd/mock-agent/handler.go b/apps/backend/cmd/mock-agent/handler.go index c8004f57d1..7c91ae4d0a 100644 --- a/apps/backend/cmd/mock-agent/handler.go +++ b/apps/backend/cmd/mock-agent/handler.go @@ -7,6 +7,8 @@ import ( "regexp" "strings" "time" + + acp "github.com/coder/acp-go-sdk" ) // delayRange returns min/max delay in milliseconds based on model name. @@ -70,6 +72,8 @@ func handlePrompt(e *emitter, prompt, model string) { emitSpecificTool(e, strings.TrimSpace(toolName), model) case strings.HasPrefix(cmd, "/subagent"): emitSubagentSequence(e, model) + case strings.EqualFold(cmd, "/subtask") || strings.HasPrefix(strings.ToLower(cmd), "/subtask "): + emitCreateSubtask(e, cmd, model) case strings.HasPrefix(cmd, "/e2e:"): rest := strings.TrimPrefix(cmd, "/e2e:") scenarioName, _, _ := strings.Cut(strings.TrimSpace(rest), " ") @@ -265,3 +269,46 @@ func emitSubagentSequence(e *emitter, model string) { randomDelay(model) e.text("Subagent task completed successfully.") } + +// emitCreateSubtask calls the kandev MCP `create_task_kandev` tool with +// parent_id="self" to create a subtask of the current task. Useful for +// manually exercising sidebar subtask UI in dev with KANDEV_MOCK_AGENT=true. +// Usage: `/subtask` or `/subtask My subtask title`. +func emitCreateSubtask(e *emitter, cmd, model string) { + title := parseSubtaskTitle(cmd) + + args := map[string]any{ + "title": title, + "parent_id": "self", + "start_agent": false, + } + + toolID := nextToolID() + e.startTool(toolID, "create_task_kandev", acp.ToolKindOther, args) + randomDelay(model) + + result, err := callMCPTool("kandev", "create_task_kandev", args) + if err != nil { + e.completeTool(toolID, map[string]any{"error": "MCP error: " + err.Error()}) + e.text(fmt.Sprintf("Failed to create subtask: %v", err)) + return + } + e.completeTool(toolID, map[string]any{"result": result}) + e.text(fmt.Sprintf("Created subtask %q under the current task.", title)) +} + +// parseSubtaskTitle extracts the title from a /subtask command. The dispatch +// matches case-insensitively, so we slice by prefix length rather than using +// strings.TrimPrefix (which would no-op on "/SubTask foo" and leak the prefix +// into the title). Returns an auto-generated title when no title is supplied. +func parseSubtaskTitle(cmd string) string { + const prefix = "/subtask" + title := "" + if len(cmd) >= len(prefix) { + title = strings.TrimSpace(cmd[len(prefix):]) + } + if title == "" { + title = fmt.Sprintf("Mock subtask %d", time.Now().Unix()%10000) + } + return title +} diff --git a/apps/backend/cmd/mock-agent/main.go b/apps/backend/cmd/mock-agent/main.go index 55992687d8..5a4e38f7cf 100644 --- a/apps/backend/cmd/mock-agent/main.go +++ b/apps/backend/cmd/mock-agent/main.go @@ -260,6 +260,7 @@ func mockAvailableCommands() []acp.AvailableCommand { {Name: "todo", Description: "Emit a todo list"}, {Name: "mermaid", Description: "Emit a mermaid diagram"}, {Name: "subagent", Description: "Emit a subagent sequence"}, + {Name: "subtask", Description: "Create a subtask of the current task via MCP", Input: hint("subtask title (optional)")}, {Name: "tool:read", Description: "Emit a read file tool call"}, {Name: "tool:edit", Description: "Emit an edit file tool call"}, {Name: "tool:exec", Description: "Emit a shell exec tool call"}, diff --git a/apps/backend/cmd/mock-agent/mock_agent_test.go b/apps/backend/cmd/mock-agent/mock_agent_test.go index be0f4bf106..79832383d7 100644 --- a/apps/backend/cmd/mock-agent/mock_agent_test.go +++ b/apps/backend/cmd/mock-agent/mock_agent_test.go @@ -3,6 +3,7 @@ package main import ( "os" "path/filepath" + "strings" "testing" ) @@ -337,6 +338,36 @@ func TestParseResumeFromArgs(t *testing.T) { } } +func TestParseSubtaskTitle(t *testing.T) { + tests := []struct { + name string + cmd string + want string + isAuto bool + }{ + {name: "lowercase no title", cmd: "/subtask", isAuto: true}, + {name: "lowercase with title", cmd: "/subtask My task", want: "My task"}, + {name: "uppercase route, mixed-case title", cmd: "/SUBTASK My Task", want: "My Task"}, + {name: "mixed-case route preserves title casing", cmd: "/SubTask Hello World", want: "Hello World"}, + {name: "extra whitespace trimmed", cmd: "/subtask trimmed ", want: "trimmed"}, + {name: "empty mixed-case route", cmd: "/SubTask", isAuto: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseSubtaskTitle(tt.cmd) + if tt.isAuto { + if !strings.HasPrefix(got, "Mock subtask ") { + t.Errorf("parseSubtaskTitle(%q) = %q, want auto-generated %q-prefixed title", tt.cmd, got, "Mock subtask ") + } + return + } + if got != tt.want { + t.Errorf("parseSubtaskTitle(%q) = %q, want %q", tt.cmd, got, tt.want) + } + }) + } +} + func TestParseFailOnResumeFromArgs(t *testing.T) { tests := []struct { name string diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index 3cd6627013..321a5adf72 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -198,7 +198,7 @@ function DiffStatsRight({ diffStats, menuOpen }: { diffStats: DiffStats; menuOpe
+{diffStats.additions}{" "} @@ -242,7 +242,6 @@ function TaskItemContent({ updatedAt, prInfo, issueInfo, - reserveMenuSpace, }: { title: string; taskId?: string; @@ -256,12 +255,9 @@ function TaskItemContent({ updatedAt?: string; prInfo?: { number: number; state: string }; issueInfo?: { url: string; number: number }; - reserveMenuSpace: boolean; }) { return ( -
+
{title} {isPinned && ( @@ -370,8 +366,9 @@ export const TaskItem = memo(function TaskItem({ updatedAt={updatedAt} prInfo={prInfo} issueInfo={issueInfo} - reserveMenuSpace={!hasDiffStats} /> + {hasDiffStats && } + {showSubtaskToggle && ( )} - {hasDiffStats && } -
); }); @@ -439,15 +434,14 @@ function SubtaskToggle({ ); } -function TaskMenuButton({ visible, shiftLeft }: { visible: boolean; shiftLeft?: boolean }) { +function TaskMenuButton({ visible }: { visible: boolean }) { return (