fix(web): keep sidebar 3-dot menu from overlapping the subtask toggle (#1012)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user