fix: constrain dialog text inputs (#1437)

This commit is contained in:
Carlos Florêncio
2026-06-18 23:03:32 +01:00
committed by GitHub
parent 3f0b391fd0
commit c30d187440
17 changed files with 202 additions and 38 deletions
+31
View File
@@ -10,6 +10,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
)
// appsDir is the apps/ workspace root relative to the working directory (apps/backend/).
@@ -185,6 +186,9 @@ func packageBundle(binDir, tarPath string) error {
// Add Vite SPA assets. `kandev start` resolves these from /app/apps/web/dist
// and passes KANDEV_WEB_DIST_DIR to the backend.
webDistDir := filepath.Join(appsDir, "web", "dist")
if err := validateViteWebDist(webDistDir); err != nil {
return err
}
if err := addDirToTar(tw, webDistDir, filepath.Join("app", "apps", "web", "dist")); err != nil {
return fmt.Errorf("add web dist: %w", err)
}
@@ -201,6 +205,33 @@ func packageBundle(binDir, tarPath string) error {
return gz.Close()
}
func validateViteWebDist(webDistDir string) error {
indexPath := filepath.Join(webDistDir, "index.html")
indexHTML, err := os.ReadFile(indexPath)
if err != nil {
return fmt.Errorf("read web dist index: %w", err)
}
if !viteIndexHasEntrypoint(string(indexHTML)) {
return fmt.Errorf("web dist index %s is missing Vite module entrypoint", indexPath)
}
return nil
}
var (
scriptTagPattern = regexp.MustCompile(`(?is)<script\b[^>]*>`)
moduleTypeAttrPattern = regexp.MustCompile(`(?is)\btype\s*=\s*["']module["']`)
assetSrcAttrPattern = regexp.MustCompile(`(?is)\bsrc\s*=\s*["']/assets/`)
)
func viteIndexHasEntrypoint(indexHTML string) bool {
for _, tag := range scriptTagPattern.FindAllString(indexHTML, -1) {
if moduleTypeAttrPattern.MatchString(tag) && assetSrcAttrPattern.MatchString(tag) {
return true
}
}
return false
}
func addFileToTar(tw *tar.Writer, src, dst string, mode fs.FileMode) error {
data, err := os.ReadFile(src)
if err != nil {
+55
View File
@@ -0,0 +1,55 @@
package main
import "testing"
func TestViteIndexHasEntrypoint(t *testing.T) {
t.Parallel()
tests := []struct {
name string
html string
want bool
}{
{
name: "vite dist index",
html: `<!doctype html><html><head><script type="module" crossorigin src="/assets/index-abc123.js"></script></head><body><div id="root"></div></body></html>`,
want: true,
},
{
name: "vite dist index with src before type and single quotes",
html: `<!doctype html><html><head><script src='/assets/index-abc123.js' crossorigin type='module'></script></head><body><div id="root"></div></body></html>`,
want: true,
},
{
name: "vite dist index with spaced attribute assignment",
html: `<!doctype html><html><head><script type = "module" crossorigin src = "/assets/index-abc123.js"></script></head><body><div id="root"></div></body></html>`,
want: true,
},
{
name: "fallback shell has no app entrypoint",
html: `<!doctype html><html><head><title>Kandev</title></head><body><div id="root"></div></body></html>`,
want: false,
},
{
name: "source index still points at vite dev source",
html: `<!doctype html><html><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>`,
want: false,
},
{
name: "inline module script plus unrelated assets reference",
html: `<!doctype html><html><head><link rel="stylesheet" href="/assets/index.css"></head><body><script type="module">console.log("x")</script></body></html>`,
want: false,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := viteIndexHasEntrypoint(tc.html); got != tc.want {
t.Fatalf("viteIndexHasEntrypoint() = %v, want %v", got, tc.want)
}
})
}
}
+3
View File
@@ -132,6 +132,9 @@ export KANDEV_DOCKER_ENABLED=false
export KANDEV_LOG_LEVEL=info
# Preview mode: only register the mock agent, suppress real agent discovery.
export KANDEV_MOCK_AGENT=only
# The preview service runs from /app, so the backend's relative dist probes do
# not reach the packaged Vite build under /app/apps/web/dist.
export KANDEV_WEB_DIST_DIR=/app/apps/web/dist
# Launch through the CLI so the backend runs under the restart supervisor.
exec kandev start \
@@ -17,6 +17,9 @@ func TestBuildExtractScript(t *testing.T) {
if !strings.Contains(script, "KANDEV_MOCK_AGENT=only") {
t.Errorf("expected KANDEV_MOCK_AGENT=only in script")
}
if !strings.Contains(script, "KANDEV_WEB_DIST_DIR=/app/apps/web/dist") {
t.Errorf("expected KANDEV_WEB_DIST_DIR to point at packaged Vite dist")
}
if !strings.Contains(script, "ln -sf /usr/local/lib/kandev-cli/bin/cli.js /usr/local/bin/kandev") {
t.Errorf("expected kandev cli symlink in script")
}
@@ -48,7 +48,10 @@ var ErrSessionResetInProgress = errors.New("session reset in progress")
var ErrSessionNotPromptable = errors.New("session not promptable")
const (
agentPromptReadyTimeout = 10 * time.Second
// Backend restart recovery can restore the session state before the ACP
// stream is promptable again. Keep this above CI's slow-start tail so a
// valid resume waits instead of surfacing "Failed to send message to agent".
agentPromptReadyTimeout = 30 * time.Second
agentPromptReadyInterval = 100 * time.Millisecond
)
+1 -1
View File
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea
data-slot="textarea"
className={cn(
"border-input bg-input/20 dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 resize-none rounded-md border px-2 py-2 text-sm transition-colors focus-visible:ring-[2px] aria-invalid:ring-[2px] md:text-xs/relaxed placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full outline-none disabled:cursor-not-allowed disabled:opacity-50",
"border-input bg-input/20 dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 resize-none rounded-md border px-2 py-2 text-sm transition-colors focus-visible:ring-[2px] aria-invalid:ring-[2px] md:text-xs/relaxed placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full min-w-0 max-w-full outline-none disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
@@ -270,7 +270,7 @@ export const InlineTaskName = memo(function InlineTaskName({
onChange={(e) => onChange(e.target.value)}
placeholder="Task name"
data-testid="task-title-input"
className="w-full border border-input bg-input/20 dark:bg-input/30 text-sm font-medium rounded-md px-3 py-2 placeholder:text-muted-foreground/70 outline-none focus-visible:border-ring transition-colors"
className="w-full min-w-0 max-w-full border border-input bg-input/20 dark:bg-input/30 text-sm font-medium rounded-md px-3 py-2 placeholder:text-muted-foreground/70 outline-none focus-visible:border-ring transition-colors"
/>
);
});
@@ -733,13 +733,13 @@ export const TaskFormInputs = memo(function TaskFormInputs({
return (
<div
className="relative"
className="relative min-w-0 max-w-full"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div
className={`rounded-md border border-input bg-transparent ${contextItems.length > 0 ? "ring-0" : ""}`}
className={`min-w-0 max-w-full rounded-md border border-input bg-transparent focus-within:ring-2 focus-within:ring-ring/30 ${contextItems.length > 0 ? "ring-0" : ""}`}
>
<ContextZone items={contextItems} />
<Textarea
@@ -756,7 +756,7 @@ export const TaskFormInputs = memo(function TaskFormInputs({
onPaste={handlePaste}
data-testid="task-description-input"
rows={2}
className={`border-0 focus-visible:ring-0 focus-visible:ring-offset-0 ${isSessionMode ? "min-h-[120px] max-h-[240px] resize-none overflow-auto text-[13px]" : "min-h-[96px] max-h-[240px] resize-y overflow-auto text-[13px]"}`}
className={`min-w-0 max-w-full field-sizing-fixed wrap-anywhere border-0 focus-visible:ring-0 focus-visible:ring-offset-0 ${isSessionMode ? "min-h-[120px] max-h-[240px] resize-none overflow-auto text-[13px]" : "min-h-[96px] max-h-[240px] resize-y overflow-auto text-[13px]"}`}
required={isSessionMode}
disabled={disabled}
/>
+5 -2
View File
@@ -493,7 +493,7 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent
data-testid="create-task-dialog"
className="w-full h-full max-w-full max-h-full rounded-none sm:w-[900px] sm:h-auto sm:max-w-none sm:max-h-[85vh] sm:rounded-lg flex flex-col"
className="w-full h-full min-w-0 max-w-full max-h-full overflow-hidden rounded-none sm:w-[900px] sm:h-auto sm:max-w-none sm:max-h-[85vh] sm:rounded-lg flex flex-col"
>
<DialogHeader>
<DialogHeaderContent
@@ -503,7 +503,10 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
initialTitle={props.initialValues?.title}
/>
</DialogHeader>
<form onSubmit={guardedHandleSubmit} className="flex flex-col gap-4 overflow-hidden">
<form
onSubmit={guardedHandleSubmit}
className="flex min-w-0 flex-col gap-4 overflow-hidden"
>
<DialogFormBody
{...buildDialogFormBodyProps(setup, props)}
onVoiceAutoSend={handleVoiceAutoSend}
@@ -12,9 +12,9 @@ export function ContextZone({ items, sessionId }: ContextZoneProps) {
if (items.length === 0) return null;
return (
<div className="overflow-y-auto max-h-28 border-b border-border/50 shrink-0">
<div className="px-2 pt-2 pb-1 space-y-1.5">
<div className="flex items-center gap-1 flex-wrap px-0 py-0.5">
<div className="max-h-28 min-w-0 shrink-0 overflow-y-auto border-b border-border/50">
<div className="min-w-0 space-y-1.5 px-2 pt-2 pb-1">
<div className="flex min-w-0 flex-wrap items-center gap-1 px-0 py-0.5">
{items.map((item) => (
<ContextItemRenderer key={item.id} item={item} sessionId={sessionId} />
))}
@@ -402,7 +402,7 @@ function NewSessionForm({
);
return (
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="min-w-0 space-y-4">
<SessionFormHeader
executorLabel={executorLabel}
worktreeBranch={worktreeBranch}
@@ -524,9 +524,9 @@ export function NewSessionDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px]">
<DialogContent className="min-w-0 overflow-hidden sm:max-w-[520px]">
<DialogHeader>
<DialogTitle className="text-sm font-medium">
<DialogTitle className="min-w-0 wrap-break-word pr-6 text-sm font-medium">
{handoffLabel ? (
<>
Hand off to <span className="text-foreground">{handoffLabel}</span>
@@ -52,13 +52,18 @@ export function SessionPromptField({
onFileInputChange,
}: SessionPromptFieldProps) {
return (
<div className="relative" onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}>
<div className="rounded-md border border-input bg-transparent">
<div
className="relative min-w-0 max-w-full"
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
<div className="min-w-0 max-w-full rounded-md border border-input bg-transparent focus-within:ring-2 focus-within:ring-ring/30">
<ContextZone items={contextItems} />
<Textarea
ref={promptRef}
placeholder="What should the agent work on?"
className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0 min-h-[120px] max-h-[240px] resize-none overflow-auto text-[13px]"
className="min-w-0 max-w-full field-sizing-fixed wrap-anywhere border-0 focus-visible:ring-0 focus-visible:ring-offset-0 min-h-[120px] max-h-[240px] resize-none overflow-auto text-[13px]"
autoFocus
disabled={isBusy}
onInput={onPromptInput}
@@ -360,10 +360,10 @@ export function NewSubtaskDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
data-testid="new-subtask-dialog"
className="w-full h-full max-w-full max-h-full rounded-none sm:w-[800px] sm:h-auto sm:max-w-none sm:max-h-[85vh] sm:rounded-lg flex flex-col"
className="w-full h-full min-w-0 max-w-full max-h-full overflow-hidden rounded-none sm:w-[800px] sm:h-auto sm:max-w-none sm:max-h-[85vh] sm:rounded-lg flex flex-col"
>
<DialogHeader>
<DialogTitle className="text-sm font-medium">
<DialogTitle className="min-w-0 wrap-break-word pr-6 text-sm font-medium">
New subtask for <span className="text-foreground">{parentTaskTitle}</span>
</DialogTitle>
</DialogHeader>
@@ -128,17 +128,17 @@ export function PromptZone({
const inputDisabled = isCreating || isSummarizing;
return (
<div
className="relative"
className="relative min-w-0 max-w-full"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="rounded-md border border-input bg-transparent">
<div className="min-w-0 max-w-full rounded-md border border-input bg-transparent focus-within:ring-2 focus-within:ring-ring/30">
<ContextZone items={contextItems} />
<Textarea
ref={promptRef}
placeholder="What should the agent work on?"
className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0 min-h-[120px] max-h-[240px] resize-none overflow-auto text-[13px]"
className="min-w-0 max-w-full field-sizing-fixed wrap-anywhere border-0 focus-visible:ring-0 focus-visible:ring-offset-0 min-h-[120px] max-h-[240px] resize-none overflow-auto text-[13px]"
autoFocus
disabled={inputDisabled}
data-testid="subtask-prompt-input"
@@ -400,7 +400,7 @@ export function SubtaskFormBody({
const showWorktreeBadge = shouldShowWorktreeBadge(fs, worktreeBranch, parentRepositoryId);
const inheritParent = workspaceMode === "inherit_parent";
return (
<form onSubmit={onSubmit} className="space-y-4">
<form onSubmit={onSubmit} className="min-w-0 space-y-4">
<div className="space-y-1.5">
<label htmlFor="subtask-title-input" className="text-xs font-medium text-muted-foreground">
Title
@@ -410,7 +410,7 @@ export function SubtaskFormBody({
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Subtask title"
className="text-sm"
className="min-w-0 max-w-full text-sm"
data-testid="subtask-title-input"
disabled={isCreating}
/>
@@ -33,19 +33,21 @@ export function EnvironmentBadges({
description?: string;
}) {
return (
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<div className="flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
{executorLabel && (
<Badge variant="secondary" className="text-xs font-normal">
{executorLabel}
</Badge>
)}
{worktreeBranch && (
<Badge variant="outline" className="text-xs font-normal gap-1">
<Badge variant="outline" className="min-w-0 max-w-full gap-1 text-xs font-normal">
<IconGitBranch className="h-3 w-3" />
{worktreeBranch}
<span className="min-w-0 truncate">{worktreeBranch}</span>
</Badge>
)}
<span>{description ?? "Same environment as current session"}</span>
<span className="min-w-0 break-words">
{description ?? "Same environment as current session"}
</span>
</div>
);
}
@@ -80,9 +82,9 @@ export function ContextSelect({
return (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Context</label>
<div className="flex items-center gap-2">
<div className="flex min-w-0 items-center gap-2">
<Select value={value} onValueChange={onValueChange} disabled={isSummarizing}>
<SelectTrigger className="w-full text-xs">
<SelectTrigger className="w-full min-w-0 text-xs">
<SelectValue>{isSummarizing ? "Summarizing..." : displayLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
@@ -47,9 +47,10 @@ async function getSidebarWidth(sidebar: Locator): Promise<number> {
return Math.round(box.width);
}
// boundingBox returns floats and sub-pixel rounding can shift the measured
// width by a pixel across renderers, so compare with a small tolerance.
function expectWidthNear(actual: number, expected: number, tolerance = 1) {
// boundingBox returns rendered border-box floats; flex layout, borders, and
// sub-pixel rounding can shift the measured width by a couple of pixels across
// CI/browser environments, so compare with a small tolerance.
function expectWidthNear(actual: number, expected: number, tolerance = 2) {
expect(actual).toBeGreaterThanOrEqual(expected - tolerance);
expect(actual).toBeLessThanOrEqual(expected + tolerance);
}
@@ -66,7 +67,7 @@ async function dragHandle(testPage: Page, handle: Locator, deltaX: number) {
}
test.describe("Review dialog sidebar resize", () => {
test.describe.configure({ retries: 2, timeout: 120_000 });
test.describe.configure({ timeout: 120_000 });
test("dragging the handle resizes the sidebar and persists to sessionStorage", async ({
testPage,
@@ -19,9 +19,6 @@ async function openTaskSession(page: Page, title: string): Promise<SessionPage>
}
test.describe("Executor not found after backend restart", () => {
// Backend restart tests can be flaky
test.describe.configure({ retries: 1 });
test("agent starts successfully when sending new prompt after backend restart", async ({
testPage,
apiClient,
@@ -68,6 +65,6 @@ test.describe("Executor not found after backend restart", () => {
await session.sendMessage("/e2e:simple-message");
// 7. The agent should respond successfully (not fail with executor not found)
await session.expectChatResponseVisible("simple mock response", 1, { timeout: 30_000 });
await session.expectChatResponseVisible("simple mock response", 1, { timeout: 60_000 });
});
});
@@ -0,0 +1,61 @@
import { test, expect } from "../../fixtures/test-base";
import { assertNoDescendantOverflowsRight } from "../../helpers/layout-assertions";
import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
// Exercises the regular task/subtask dialogs, so run with the office feature disabled.
useRegularMode();
const LONG_UNBROKEN_TEXT = `review-${"x".repeat(240)}-https://github.com/example/repo/commit/${"a".repeat(80)}`;
test.describe("Dialog long text layout", () => {
test("agent, task, and subtask dialogs stay within their modal bounds", async ({
testPage,
apiClient,
seedData,
}) => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.createTaskButton.first().click();
const createDialog = testPage.getByTestId("create-task-dialog");
await expect(createDialog).toBeVisible();
await testPage.getByTestId("task-title-input").fill(LONG_UNBROKEN_TEXT);
await testPage.getByTestId("task-description-input").fill(LONG_UNBROKEN_TEXT);
await assertNoDescendantOverflowsRight(createDialog, "create task dialog");
await testPage.keyboard.press("Escape");
await expect(createDialog).not.toBeVisible();
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Long Text Dialog Parent",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.openNewSessionDialog();
const newSessionDialog = session.newSessionDialog();
await expect(newSessionDialog).toBeVisible();
await session.newSessionPromptInput().fill(LONG_UNBROKEN_TEXT);
await assertNoDescendantOverflowsRight(newSessionDialog, "new agent dialog");
await testPage.keyboard.press("Escape");
await expect(newSessionDialog).not.toBeVisible();
await testPage.getByTestId("sidebar-new-subtask").click();
const subtaskDialog = testPage.getByTestId("new-subtask-dialog");
await expect(subtaskDialog).toBeVisible();
await testPage.getByTestId("subtask-title-input").fill(LONG_UNBROKEN_TEXT);
await testPage.getByTestId("subtask-prompt-input").fill(LONG_UNBROKEN_TEXT);
await assertNoDescendantOverflowsRight(subtaskDialog, "new subtask dialog");
});
});