fix(web): keep sidebar 3-dot menu from overlapping the subtask toggle (#1012)

This commit is contained in:
Carlos Florêncio
2026-05-22 15:36:46 +01:00
committed by GitHub
parent 2430d351e6
commit a2b4fc0c6d
4 changed files with 88 additions and 15 deletions
+47
View File
@@ -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
}
+1
View File
@@ -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"},
@@ -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
+9 -15
View File
@@ -198,7 +198,7 @@ function DiffStatsRight({ diffStats, menuOpen }: { diffStats: DiffStats; menuOpe
<div
className={cn(
"shrink-0 self-center font-mono text-[11px] transition-opacity duration-100",
menuOpen ? "opacity-0" : "group-hover:opacity-0",
menuOpen ? "opacity-0" : "group-hover:opacity-0 group-focus-within:opacity-0",
)}
>
<span className="text-emerald-500">+{diffStats.additions}</span>{" "}
@@ -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 (
<div
className={cn("flex min-w-0 flex-1 flex-col gap-0.5", reserveMenuSpace && "group-hover:pr-5")}
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex items-center gap-1 min-w-0 text-[13px] font-medium text-foreground leading-tight">
<ScrollOnOverflow className="min-w-0">{title}</ScrollOnOverflow>
{isPinned && (
@@ -370,8 +366,9 @@ export const TaskItem = memo(function TaskItem({
updatedAt={updatedAt}
prInfo={prInfo}
issueInfo={issueInfo}
reserveMenuSpace={!hasDiffStats}
/>
{hasDiffStats && <DiffStatsRight diffStats={diffStats!} menuOpen={effectiveMenuOpen} />}
<TaskMenuButton visible={effectiveMenuOpen} />
{showSubtaskToggle && (
<SubtaskToggle
taskId={taskId}
@@ -380,8 +377,6 @@ export const TaskItem = memo(function TaskItem({
onToggle={onToggleSubtasks!}
/>
)}
{hasDiffStats && <DiffStatsRight diffStats={diffStats!} menuOpen={effectiveMenuOpen} />}
<TaskMenuButton visible={effectiveMenuOpen} shiftLeft={showSubtaskToggle} />
</div>
);
});
@@ -439,15 +434,14 @@ function SubtaskToggle({
);
}
function TaskMenuButton({ visible, shiftLeft }: { visible: boolean; shiftLeft?: boolean }) {
function TaskMenuButton({ visible }: { visible: boolean }) {
return (
<div
className={cn(
"absolute inset-y-0 flex items-center gap-0.5 transition-opacity duration-100",
// When a subtask toggle is present on the right, push the menu button
// left of it so the two controls don't overlap on hover.
shiftLeft ? "right-10" : "right-1",
visible ? "opacity-100" : "opacity-0 group-hover:opacity-100",
"self-center shrink-0 flex items-center transition-opacity duration-100",
visible
? "opacity-100"
: "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto",
)}
>
<button