Improve task creation dialog (vibe-kanban) (#1007)

* Streamline task creation dialog with improved UX

- Wider dialog (40rem max width, grows to 95vh height)
- Clean input styling without labels
- Auto-growing description field (4-30 rows)
- Horizontal scrollable template section (shows only when form is empty)
- Smooth collapse animations for templates and image upload
- Combined agent+configuration selector matching BranchSelector design
- Direct file selection via image button (no drag/drop zone)
- Paste images directly into description
- Segmented control for Create/Start modes
- Agent/branch selectors only visible in Start mode
- Integrated keyboard shortcuts (Cmd+Enter for primary, Cmd+Shift+Enter for alternate)
- Removed unnecessary spacing and borders
- Square button design for consistent appearance

Amp-Thread-ID: https://ampcode.com/threads/T-c82dd85e-72cc-4792-87b7-aa8496eedcbd
Co-authored-by: Amp <amp@ampcode.com>

* These are all configuration errors (like --jsx flag), not errors in our file. The rebase is complete and successful! Let me create a summary:

##  **Rebase Successfully Completed!**

**Conflict Resolution Summary:**
1.  **Removed template functionality**: Main removed templates API, so I removed all template-related code
2.  **Merged image pasting**: Kept our image paste implementation with auto-show upload section
3.  **Integrated keyboard shortcuts**: Merged our shouldStart logic with main's isTextareaFocused approach
4.  **Squashed commits**: Combined 25 small commits into 1 comprehensive commit

**Key Changes Made During Resolution:**
-  **Kept Collapse component**: Module-scope component with proper animations
-  **Kept AgentSelector**: Consistent with BranchSelector design
-  **Kept segmented control**: Create/Start buttons with sliding animation
-  **Removed templates**: Template functionality no longer exists in main
-  **Merged keyboard**: Combined both approaches for best UX
-  **Kept image features**: Paste images, direct file selection, hidden drop zone

**Files Modified:**
- frontend/src/components/dialogs/tasks/TaskFormDialog.tsx
- frontend/src/components/ui/ImageUploadSection.tsx (hideDropZone prop)
- frontend/src/styles/index.css (scrollbar-hide utility)

**Final State:**
- Branch: `vk/6287-streamlined-new`
- Base: `main` (version 0.0.111)
- Status: Rebased successfully, 1 clean commit
- All checks: TaskFormDialog compiles without errors

The rebase is complete! Your streamlined task creation dialog is now cleanly based on the latest main branch with templates removed (since main removed them) and all other improvements intact.

* Cleanup script changes for task attempt 6287280d-b79c-4713-8aea-25a1cb51b894

* TaskFormDialog improvements (vibe-kanban 7f43104e)

TaskFormDialog.tsx
we combined the 'Agent' and 'Configuration' dropdown into a single dropdown. This was a mistake, lets split it into two again. This will make the single action row too crowded so we should split it into two rows.

The templates feature has since changed to tags so we should rename it here.

* remove build.rs

* task dialog code review (vibe-kanban 470400fe)

ready?

* remove css change

* task creation code review (vibe-kanban 764af9b9)

frontend/src/components/dialogs/tasks/TaskFormDialog.tsx
surely there are opportunities to simplify this code?? We can split it up to be more modular too, and follow idiomatic practices.

    // Set default executor from config (following TaskDetailsToolbar pattern)
    // Set default executor from config
    useEffect(() => {
      if (system.config?.executor_profile) {
        setSelectedExecutorProfile(system.config.executor_profile);
      }
    }, [system.config?.executor_profile]);
    // Set default executor from config (following TaskDetailsToolbar pattern)
    useEffect(() => {
      if (system.config?.executor_profile) {
        setSelectedExecutorProfile(system.config.executor_profile);
      }
    }, [system.config?.executor_profile]);
    // Handle image upload success by inserting markdown into description
    const handleImageUploaded = useCallback((image: ImageResponse) => {
      const markdownText = `![${image.original_name}](${image.file_path})`;
      setDescription((prev) => {

why did this logic change too? I think it was working fine previously.

* Fix image handling regression and reorganize TaskForm files

- Fix image paste/drag-drop on first attempt by queuing pending files until ImageUploadSection mounts
- Add DescriptionRowHandle ref to expose addFiles method
- Move TaskFormDialog and related files into TaskForm/ subdirectory
- Update all imports to reflect new file structure

* fix: high-impact task form bugs and cleanup

- Fix images not loading in edit mode by syncing directly to store
- Fix uncloseable dialog X button still allowing close
- Fix Switch accessibility with aria-label
- Fix discard dialog z-index stacking (10000 to appear above parent)
- Fix branch not being prefilled by including fetchedBranch in init
- Remove unused useTaskFormReducer and useTaskImages hooks

Amp-Thread-ID: https://ampcode.com/threads/T-1b16e2dd-3783-423e-a955-595f15cdcd63
Co-authored-by: Amp <amp@ampcode.com>

* Fix conditional hook calls in AgentSelector and ConfigSelector

Move all React hooks to be called unconditionally before early returns to comply with rules-of-hooks linter.

Amp-Thread-ID: https://ampcode.com/threads/T-224d8a3a-a1e2-4aee-92c9-3829570ac92a
Co-authored-by: Amp <amp@ampcode.com>

* Refactor: Deduplicate agent and config selection logic

- Add showLabel prop to AgentSelector and ConfigSelector for conditional label rendering
- Refactor ExecutorProfileSelector to use AgentSelector and ConfigSelector as building blocks
- Reduce ExecutorProfileSelector from 182 to 49 lines by eliminating duplicate dropdown UI/logic
- Maintain backward compatibility with CreateModeDropdownsRow (labels hidden by default)

Amp-Thread-ID: https://ampcode.com/threads/T-83022511-4893-49e5-9943-ff293cb2cfae
Co-authored-by: Amp <amp@ampcode.com>

* one file

* Consolidate task form dialog: reduce from ~15 files to 4

Massively reduced indirection in task form components:

Before:
- TaskFormDialog.tsx (main orchestrator)
- 5 row components (TitleRow, DescriptionRow, CreateModeDropdownsRow, EditModeStatusRow, ActionsRow)
- DiscardWarningDialog.tsx
- DragOverlay.tsx
- useTaskFormStore.ts (Zustand global store)
- 4 hooks (useTaskFormKeyboardShortcuts, useUnsavedChanges, useDragAndDropUpload, useTaskBranches)

After:
- TaskFormDialog.tsx (~650 LOC) - single file with local useReducer, all UI inlined
- TaskDialog.tsx (kept - reusable primitive)
- AgentSelector.tsx (kept - shared with ExecutorProfileSelector)
- ConfigSelector.tsx (kept - shared with ExecutorProfileSelector)

Changes:
- Replaced global Zustand store with local useReducer
- Inlined all row components directly into main component
- Inlined keyboard shortcuts, drag-and-drop, unsaved changes, branch fetching
- Inlined submission logic
- Removed DescriptionRow forwardRef wrapper - manage imageUploadRef directly
- Eliminated ~12 files worth of TypeScript prop overhead

Result: Easier to fit entire form logic in your head, fewer files to navigate

* remove unused variant

* run formatter

* always show branch selector remove usage of `e.returnValue = ''` move
reducer init to function instead of useEffect.

* remove reducer log

* Prevent branch selector from growing with long branch names

- Add flex-1 min-w-0 to all three selectors (Agent, Config, Branch) in TaskFormDialog to share space equally
- Add truncation and flex constraints to branch name in BranchSelector dropdown rows
- Prevent icons from shrinking with flex-shrink-0

Amp-Thread-ID: https://ampcode.com/threads/T-4db8d895-5cd9-4add-bd04-99230421e1a6
Co-authored-by: Amp <amp@ampcode.com>

* always show all selectors in create mode

* format

* Show 'Starting...' instead of 'Creating...' when auto-start is enabled

Amp-Thread-ID: https://ampcode.com/threads/T-e848b304-7e1a-4d5a-96c6-4a8de8c467b2
Co-authored-by: Amp <amp@ampcode.com>

* Add i18n support to TaskFormDialog with translations for en, ja, ko, es

Amp-Thread-ID: https://ampcode.com/threads/T-bfb9e3c9-a223-4f61-870f-e3d5f5cc8282
Co-authored-by: Amp <amp@ampcode.com>

* scrollable task images

* Update TaskFormDialog and TextArea components

Refactor task form layout and add textarea scroll control

* format

* Reset modal state when discarding changes in TaskFormDialog

Amp-Thread-ID: https://ampcode.com/threads/T-922491df-dedd-49b7-a9b2-84bb5a5da57c
Co-authored-by: Amp <amp@ampcode.com>

* Apply rounded corners to TaskDialog at all screen sizes

Amp-Thread-ID: https://ampcode.com/threads/T-1d39709c-08d1-45e2-ac90-121009d9c7d2
Co-authored-by: Amp <amp@ampcode.com>

* fix linter

* default rows to 20

* update text style

* refactor: replace direct API calls with hooks in TaskFormDialog

- Created useProjectBranches hook for fetching project branches
- Created useImageUpload hook for image upload/delete operations
- Replaced direct projectsApi, attemptsApi, and imagesApi calls with hooks
- Simplified useEffect logic by leveraging React Query hooks

Amp-Thread-ID: https://ampcode.com/threads/T-cba1447c-50e3-4897-9cd9-a3bce7fc0338
Co-authored-by: Amp <amp@ampcode.com>

* use shadcn switch

* resolve conflict in package.json

* reset TaskFormDialog to initial state when discarding changes

* Refactor to use ExecutorProfileSelector in TaskFormDialog

- Add className and itemClassName props to ExecutorProfileSelector for flexible styling
- Replace separate AgentSelector + ConfigSelector with unified ExecutorProfileSelector in TaskFormDialog
- Maintain equal width distribution across agent, config, and branch selectors

Amp-Thread-ID: https://ampcode.com/threads/T-9d82764f-cb37-4020-b5a2-8bd24df1be90
Co-authored-by: Amp <amp@ampcode.com>

* Reset form state in TaskFormDialog when dialog opens or task changes

* streamlined tk scenarios (vibe-kanban 845b2e25)

frontend/src/components/dialogs/tasks/TaskFormDialog.tsx
I am experiencing the following bug:

<bug>
Context
TaskFormDialog is a modal for creating/editing tasks. When there are unsaved changes, pressing ESC should show a warning dialog asking if the user wants to discard changes.
Test Scenarios & Last Reported Status
Scenario 1: No changes + focused input
Action: Open dialog → title field is autofocused → press ESC once
Expected: Dialog closes immediately
Last Reported:  FAIL - ESC does nothing (after Input blur behavior was removed)
Scenario 2: No changes + unfocused
Action: Open dialog → click outside input to unfocus → press ESC once
Expected: Dialog closes immediately
Last Reported:  PASS
Scenario 3: With changes + focused input
Action: Open dialog → type in title field (remains focused) → press ESC
Expected:
ESC #1: Warning dialog appears immediately
ESC #2: Warning dialog closes (return to task form)
ESC #3: Warning dialog appears again
Last Reported:  FAIL - Warning opens on ESC #1, but subsequent ESC presses do nothing
Scenario 4: With changes + unfocused
Action: Open dialog → type in field → click outside to unfocus → press ESC
Expected: Same as Scenario 3
Last Reported:  FAIL - Closes the underlying kanban board while keeping the dialog visible
Scenario 5: Warning → Continue Editing → ESC again
Action: Open dialog → make changes → ESC (warning appears) → click "Continue Editing" button → ESC again
Expected: Warning dialog should reappear
Last Reported:  FAIL - Closes the underlying kanban board instead
Root Cause (From Console Logs)
The warning Dialog component manages keyboard scopes independently, causing scope conflicts with the parent TaskDialog. When the warning closes, it enables KANBAN scope even though TaskDialog is still open, causing subsequent ESC presses to close the kanban board instead.
</bug>

I need your help to identify the exact cause of this bug and implement an effective solution. To do this, carefully follow the workflow below, in this specific order:

---

## Workflow

### **Step 1: Clarification (if needed)**

- If any part of this prompt is unclear or confusing, ask clarifying questions before proceeding.
- Do not ask questions unnecessarily… only ask if essential information is missing.

---

### **Step 2: Initial Analysis**

- Quickly review the relevant code to understand the bug's surface area.
- Identify key execution paths and data flows related to the bug.
- **Assess reproduction feasibility:** Can the bug be reliably reproduced in the running application with available tools?
- **Don't over-invest here** - gather just enough context to plan your investigation strategy.

---

### **Step 3: Choose Investigation Strategy**

Based on your Step 2 assessment, select one of three paths:

#### **Path A: Direct Observation (STRONGLY PREFERRED)**

**When to use:**

- Bug can be reproduced in the running application
- Available tools (browser, network requests, console logs, tmux sessions) are sufficient to observe the issue
- **This is the default choice - only deviate if you have a compelling reason**

**Why this is preferred:**

- Tests the actual application behaviour
- Captures real-world interactions and state
- Provides the most accurate diagnostic information
- Fixes are validated in the true environment

**Proceed to Step 4**

---

#### **Path B: Isolated Prototype (use sparingly)**

**When to use (rare cases only):**

- Bug involves a complex algorithm or data structure that can be completely isolated from application context
- The issue is conceptually pure and self-contained
- Full application context adds overwhelming noise that makes diagnosis impossible
- Example: "Custom sorting algorithm produces incorrect order for specific edge case"

**What to do:**

- Create a from-scratch minimal reproduction:
    - **Backend:** New isolated crate with focused unit tests
    - **Frontend:** New Vite project with just the problem component/logic
- Debug in this controlled environment
- Once understood, apply the fix to the main codebase
- **Skip to Step 10**

**Note:** Human intervention is unlikely to be needed with this approach since you control the entire reproduction environment.

---

#### **Path C: Prototyping Playground (use sparingly)**

**When to use (rare cases only):**

- Bug requires some application context (routes, API, state management) but the full production setup has too many confounding variables
- You need to iterate quickly on a specific feature without affecting the main application
- Example: "Auth flow fails under specific user state conditions that are difficult to reproduce"

**What to do:**

- Create a focused testing ground within the application:
    - **Frontend:** New route (e.g., `/debug-auth-flow`) that isolates the problematic feature
    - **Backend:** New module/crate with comprehensive unit tests targeting the issue
- Instrument and test in this playground
- Apply learnings to the main implementation
- **Skip to Step 10**

**Note:** Human intervention is unlikely to be needed with this approach since you're building a controlled test environment.

---

### **Step 4: Design Instrumentation Strategy** (Path A only)

- Determine what information would definitively diagnose the root cause.
- Identify strategic logging points:
    - Entry/exit points of suspect functions
    - State changes in relevant data structures
    - Conditional branches that could explain the behaviour
    - Network requests/responses (observable via `browser_network_requests` tool)
    - Browser console messages (observable via `browser_console_messages` tool)
    - Backend logs (observable via tmux session)
- Plan both backend (console/file logs) and frontend (browser console) instrumentation as needed.
- **Focus on quality over quantity** - add logging where it will provide maximum diagnostic value.

---

### **Step 5: Implement Logging** (Path A only)

- Add comprehensive, structured logging at identified points.
- Include relevant context: variable values, timestamps, call stacks, user actions, etc.
- Make logs easily grep-able/filterable with clear prefixes (e.g., `[BUG_DEBUG]`).
- Ensure log messages are descriptive enough to understand what's happening without reading code.

---

### **Step 6: Run & Observe** (Path A only)

- Start the application in a new tmux session (for backend logs).
- Use `browser_console_messages` to monitor frontend logs.
- Use `browser_network_requests` to observe API/network activity.
- Attempt to reproduce the bug with instrumentation active.
- Collect and analyse log output from all sources.

**Human Intervention Point:**

If reproduction fails or observations are inconclusive:

- Explicitly request human assistance.
- Explain what was attempted and what information is still needed.
- Suggest specific ways the human could help (e.g., "Could you reproduce the bug and share the exact steps?" or "Can you verify if X behaviour occurs when you do Y?").
- Provide clear context so the human can help efficiently.

---

### **Step 7: Diagnose from Evidence** (Path A only)

- Review actual runtime behaviour from logs, network requests, and console messages.
- Identify the precise failure point and root cause.
- **Base your diagnosis on observed facts, not hypotheses.**
- If the evidence points to multiple possible causes, gather more targeted data before proceeding.

---

### **Step 8: Implement Fix** (Path A only)

- Fix directly in the current worktree based on evidence from Step 7.
- Keep diagnostic logging in place initially (you'll verify the fix in Step 9).
- Ensure the fix addresses the root cause, not just the symptoms.

---

### **Step 9: Verify Fix** (Path A only)

- Run the application again with logging still active.
- Reproduce the original bug scenario.
- Confirm the bug is resolved through observed behaviour.
- Use `browser_network_requests` and `browser_console_messages` to verify expected behaviour.
- Compare "before" and "after" logs if helpful.

**Human Intervention Point:**

If verification is unclear or requires domain knowledge:

- Explicitly request human verification.
- Provide clear, step-by-step instructions for what to test.
- Explain what success looks like (expected vs actual behaviour).
- Share relevant log excerpts or observations that informed your fix.

---

### **Step 10: Report to User**

**For Path A (Direct Observation):**

Provide a clear summary including:

- **Root cause:** Explain what was actually happening based on observed evidence
- **Diagnostic process:** Briefly describe how logging/observation revealed the issue
- **Implemented fix:** Describe the changes made and why they address the root cause
- **Verification results:** Confirm the fix works (or request human verification if needed)

**For Path B/C (Prototype/Playground):**

Provide a clear summary including:

- **Why this approach was chosen:** Explain why direct observation wasn't suitable
- **What was learned:** Describe insights gained from the isolated environment
- **How the fix was applied:** Explain how learnings translated to the main codebase
- **Relevant artefacts:** Share any reproduction code, tests, or documentation created

---

### **Step 11: Automation Improvement Plan** (optional)

**Only include this section if:**

- The diagnosis was more difficult or time-consuming than it should have been, OR
- You encountered obstacles that could be prevented with codebase improvements, OR
- You required human intervention during the process

**What to include:**

- Analyse what made this bug difficult to diagnose automatically
- Propose specific, actionable codebase changes that would improve future automation:
    - **Accessibility improvements:** ARIA labels, test IDs, semantic HTML (dual benefit: improved accessibility for users + easier automation)
    - **Logging enhancements:** Structured logging, better error messages, trace IDs, contextual information
    - **Testability improvements:** Dependency injection, pure functions, better component boundaries
    - **Observability additions:** Health checks, metrics endpoints, debug modes, feature flags
- Categorise suggestions by impact and implementation effort
- **Important:** Suggestions should be practical and should not sacrifice application quality, performance, or maintainability

---

### **Step 12: Clean Up** (optional, Path A only)

- Remove or reduce instrumentation to production-appropriate levels.
- Keep any logging that would be valuable for future debugging.
- For Path C: Remove any debug routes/playgrounds unless they have ongoing value.
- Commit your changes with a clear, descriptive commit message.

---

## Key Principles

1. **Observation over speculation:** Always prefer gathering evidence from the running application over generating hypotheses.

2. **Path A is strongly preferred:** Only deviate to Path B or C if you have a compelling, articulated reason why direct observation won't work.

3. **Request human help when needed:** If you're stuck, be explicit about it. Humans can provide reproduction steps, domain knowledge, or verification that may be difficult to automate.

4. **Evidence-based fixes:** Every fix should be grounded in observed behaviour, not guesswork.

5. **Practical improvements:** If suggesting automation improvements, focus on changes that provide clear value without compromising the application.

* formatter

* tanstack form docs

* create plan

* doc

* migrate TaskFormDialog to tanstack form

* remove docs

* run formatter

* Fix: prevent discard warning when no user changes made

Use dontUpdateMeta option when programmatically setting branch value to avoid marking form as dirty on initialization

Amp-Thread-ID: https://ampcode.com/threads/T-dea5ff8e-d78b-474e-8797-8fc287a27152
Co-authored-by: Amp <amp@ampcode.com>

* Search should be positioned relative to caret, not textarea (vibe-kanban 751134be)

frontend/src/components/ui/file-search-textarea.tsx

* use existing dialog (vibe-kanban 69528431)

frontend/src/components/dialogs/tasks/TaskFormDialog.tsx
TaskDialog.tsx

We have created a new dialog component. Can we reuse the existing one? Will this cause any regressions?

* Use TanStack Form validators for TaskFormDialog validation

- Add field-level validators (onMount + onChange) for title, executorProfileId, and branch
- Remove inline validation logic from Subscribe block
- Extract shared validator functions to avoid duplication
- Button disabled state now uses form.state.canSubmit directly
- Validators run on mount to ensure correct initial state

Amp-Thread-ID: https://ampcode.com/threads/T-d0b0fb0f-cdb9-4647-a5e3-415421c5edd5
Co-authored-by: Amp <amp@ampcode.com>

* Fix dialog close button not clickable due to z-index issue

Add z-10 class to the close button to ensure it appears above dialog
content and remains clickable. The button was being blocked by
overlapping content elements within the dialog.

Amp-Thread-ID: https://ampcode.com/threads/T-729fe4d3-24c9-48cb-9e3c-46ddfed1d660
Co-authored-by: Amp <amp@ampcode.com>

* formatter

* update pnpm lock

* revert changes to dialog.tsx

* bring back z-10 (if p-0 is set then this is necessary)

* Revert "Use TanStack Form validators for TaskFormDialog validation"

This reverts commit 6d946dd88a6ae0c341943d1adcc25261743bfad5.

* update title validator

* reactive form state

* update effect

* localise `dropImagesHere` text use form level validation over field
level validation make autoStart a form field s.t. it triggers form level
validation on change use react-dropzone to implement the image upload
button remove unnecessary usage of useCallback simplify handleSubmit
function (no useCallback, assume valid values after form validation,
unify task variable) remove showImageUpload state create editMode
variable use canSubmit to control primary action button disabled state
extract warning dialog to its own component

* update loading handling

* update hook import

* update pnpm lock

* tsc

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Louis Knight-Webb <louis@bloop.ai>
This commit is contained in:
Britannio Jarrett
2025-11-17 15:21:36 +00:00
committed by GitHub
parent bb4136a14e
commit d578b6f586
26 changed files with 2842 additions and 2078 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -91,6 +91,7 @@ export const ViewRelatedTasksDialog =
await Promise.resolve();
await openTaskForm({
mode: 'subtask',
projectId,
parentTaskAttemptId: attempt.id,
initialBaseBranch: attempt.branch || attempt.target_branch,

View File

@@ -108,7 +108,7 @@ export function Navbar() {
const handleCreateTask = () => {
if (projectId) {
openTaskForm({ projectId });
openTaskForm({ mode: 'create', projectId });
}
};

View File

@@ -1,17 +1,7 @@
import { Settings2, ArrowDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Label } from '@/components/ui/label';
import type {
BaseCodingAgent,
ExecutorConfig,
ExecutorProfileId,
} from 'shared/types';
import { AgentSelector } from '@/components/tasks/AgentSelector';
import { ConfigSelector } from '@/components/tasks/ConfigSelector';
import { cn } from '@/lib/utils';
import type { ExecutorConfig, ExecutorProfileId } from 'shared/types';
type Props = {
profiles: Record<string, ExecutorConfig> | null;
@@ -19,7 +9,8 @@ type Props = {
onProfileSelect: (profile: ExecutorProfileId) => void;
disabled?: boolean;
showLabel?: boolean;
showVariantSelector?: boolean;
className?: string;
itemClassName?: string;
};
function ExecutorProfileSelector({
@@ -28,153 +19,31 @@ function ExecutorProfileSelector({
onProfileSelect,
disabled = false,
showLabel = true,
showVariantSelector = true,
className,
itemClassName,
}: Props) {
if (!profiles) {
return null;
}
const handleExecutorChange = (executor: string) => {
onProfileSelect({
executor: executor as BaseCodingAgent,
variant: null,
});
};
const handleVariantChange = (variant: string) => {
if (selectedProfile) {
onProfileSelect({
...selectedProfile,
variant: variant === 'DEFAULT' ? null : variant,
});
}
};
const currentProfile = selectedProfile
? profiles[selectedProfile.executor]
: null;
const hasVariants = currentProfile && Object.keys(currentProfile).length > 0;
return (
<div className="flex gap-3 flex-col sm:flex-row">
{/* Executor Profile Selector */}
<div className="flex-1">
{showLabel && (
<Label htmlFor="executor-profile" className="text-sm font-medium">
Agent
</Label>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-full justify-between text-xs mt-1.5"
disabled={disabled}
>
<div className="flex items-center gap-1.5">
<Settings2 className="h-3 w-3" />
<span className="truncate">
{selectedProfile?.executor || 'Select profile'}
</span>
</div>
<ArrowDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-full">
{Object.keys(profiles)
.sort((a, b) => a.localeCompare(b))
.map((executorKey) => (
<DropdownMenuItem
key={executorKey}
onClick={() => handleExecutorChange(executorKey)}
className={
selectedProfile?.executor === executorKey ? 'bg-accent' : ''
}
>
{executorKey}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Variant Selector (conditional) */}
{showVariantSelector &&
selectedProfile &&
hasVariants &&
currentProfile && (
<div className="flex-1">
<Label htmlFor="executor-variant" className="text-sm font-medium">
Configuration
</Label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-full justify-between text-xs mt-1.5"
disabled={disabled}
>
<span className="truncate">
{selectedProfile.variant || 'DEFAULT'}
</span>
<ArrowDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-full">
{Object.keys(currentProfile).map((variantKey) => (
<DropdownMenuItem
key={variantKey}
onClick={() => handleVariantChange(variantKey)}
className={
selectedProfile.variant === variantKey ? 'bg-accent' : ''
}
>
{variantKey}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{/* Show disabled variant selector for profiles without variants */}
{showVariantSelector &&
selectedProfile &&
!hasVariants &&
currentProfile && (
<div className="flex-1">
<Label htmlFor="executor-variant" className="text-sm font-medium">
Configuration
</Label>
<Button
variant="outline"
size="sm"
disabled
className="w-full text-xs justify-start mt-1.5"
>
Default
</Button>
</div>
)}
{/* Show placeholder for variant when no profile selected */}
{showVariantSelector && !selectedProfile && (
<div className="flex-1">
<Label htmlFor="executor-variant" className="text-sm font-medium">
Configuration
</Label>
<Button
variant="outline"
size="sm"
disabled
className="w-full text-xs justify-start mt-1.5"
>
Select agent first
</Button>
</div>
)}
<div className={cn('flex gap-3 flex-col sm:flex-row', className)}>
<AgentSelector
profiles={profiles}
selectedExecutorProfile={selectedProfile}
onChange={onProfileSelect}
disabled={disabled}
showLabel={showLabel}
className={itemClassName}
/>
<ConfigSelector
profiles={profiles}
selectedExecutorProfile={selectedProfile}
onChange={onProfileSelect}
disabled={disabled}
showLabel={showLabel}
className={itemClassName}
/>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { Bot, ArrowDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Label } from '@/components/ui/label';
import type { ExecutorProfileId, BaseCodingAgent } from 'shared/types';
interface AgentSelectorProps {
profiles: Record<string, Record<string, unknown>> | null;
selectedExecutorProfile: ExecutorProfileId | null;
onChange: (profile: ExecutorProfileId) => void;
disabled?: boolean;
className?: string;
showLabel?: boolean;
}
export function AgentSelector({
profiles,
selectedExecutorProfile,
onChange,
disabled,
className = '',
showLabel = false,
}: AgentSelectorProps) {
const agents = profiles
? (Object.keys(profiles).sort() as BaseCodingAgent[])
: [];
const selectedAgent = selectedExecutorProfile?.executor;
if (!profiles) return null;
return (
<div className="flex-1">
{showLabel && (
<Label htmlFor="executor-profile" className="text-sm font-medium">
Agent
</Label>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={`w-full justify-between text-xs ${showLabel ? 'mt-1.5' : ''} ${className}`}
disabled={disabled}
aria-label="Select agent"
>
<div className="flex items-center gap-1.5 w-full">
<Bot className="h-3 w-3" />
<span className="truncate">{selectedAgent || 'Agent'}</span>
</div>
<ArrowDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-60">
{agents.length === 0 ? (
<div className="p-2 text-sm text-muted-foreground text-center">
No agents available
</div>
) : (
agents.map((agent) => (
<DropdownMenuItem
key={agent}
onClick={() => {
onChange({
executor: agent,
variant: null,
});
}}
className={selectedAgent === agent ? 'bg-accent' : ''}
>
{agent}
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -64,9 +64,11 @@ const BranchRow = memo(function BranchRow({
disabled={isDisabled}
className={classes.trim()}
>
<div className="flex items-center justify-between w-full">
<span className={nameClass}>{branch.name}</span>
<div className="flex gap-1">
<div className="flex items-center justify-between w-full gap-2">
<span className={`${nameClass} truncate flex-1 min-w-0`}>
{branch.name}
</span>
<div className="flex gap-1 flex-shrink-0">
{branch.is_current && (
<span className="text-xs bg-background px-1 rounded">
{t('branchSelector.badges.current')}
@@ -209,13 +211,13 @@ function BranchSelector({
size="sm"
className={`w-full justify-between text-xs ${className}`}
>
<div className="flex items-center gap-1.5 w-full">
<GitBranchIcon className="h-3 w-3" />
<div className="flex items-center gap-1.5 w-full min-w-0">
<GitBranchIcon className="h-3 w-3 flex-shrink-0" />
<span className="truncate">
{selectedBranch || effectivePlaceholder}
</span>
</div>
<ArrowDown className="h-3 w-3" />
<ArrowDown className="h-3 w-3 flex-shrink-0" />
</Button>
</DropdownMenuTrigger>

View File

@@ -0,0 +1,89 @@
import { Settings2, ArrowDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Label } from '@/components/ui/label';
import type { ExecutorProfileId } from 'shared/types';
interface ConfigSelectorProps {
profiles: Record<string, Record<string, unknown>> | null;
selectedExecutorProfile: ExecutorProfileId | null;
onChange: (profile: ExecutorProfileId) => void;
disabled?: boolean;
className?: string;
showLabel?: boolean;
}
export function ConfigSelector({
profiles,
selectedExecutorProfile,
onChange,
disabled,
className = '',
showLabel = false,
}: ConfigSelectorProps) {
const selectedAgent = selectedExecutorProfile?.executor;
const configs = selectedAgent && profiles ? profiles[selectedAgent] : null;
const configOptions = configs ? Object.keys(configs).sort() : [];
const selectedVariant = selectedExecutorProfile?.variant || 'DEFAULT';
if (
!selectedAgent ||
!profiles ||
!configs ||
Object.keys(configs).length === 0
)
return null;
return (
<div className="flex-1">
{showLabel && (
<Label htmlFor="executor-variant" className="text-sm font-medium">
Configuration
</Label>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={`w-full justify-between text-xs ${showLabel ? 'mt-1.5' : ''} ${className}`}
disabled={disabled}
aria-label="Select configuration"
>
<div className="flex items-center gap-1.5 w-full">
<Settings2 className="h-3 w-3" />
<span className="truncate">{selectedVariant}</span>
</div>
<ArrowDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-60">
{configOptions.map((variant) => (
<DropdownMenuItem
key={variant}
onClick={() => {
onChange({
executor: selectedAgent,
variant: variant === 'DEFAULT' ? null : variant,
});
}}
className={
(variant === 'DEFAULT' ? null : variant) ===
selectedExecutorProfile?.variant
? 'bg-accent'
: ''
}
>
{variant}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -43,13 +43,13 @@ export function ActionsDropdown({
const handleEdit = (e: React.MouseEvent) => {
e.stopPropagation();
if (!projectId || !task) return;
openTaskForm({ projectId, task });
openTaskForm({ mode: 'edit', projectId, task });
};
const handleDuplicate = (e: React.MouseEvent) => {
e.stopPropagation();
if (!projectId || !task) return;
openTaskForm({ projectId, initialTask: task });
openTaskForm({ mode: 'duplicate', projectId, initialTask: task });
};
const handleDelete = async (e: React.MouseEvent) => {
@@ -103,10 +103,13 @@ export function ActionsDropdown({
const handleCreateSubtask = (e: React.MouseEvent) => {
e.stopPropagation();
if (!projectId || !attempt) return;
const baseBranch = attempt.branch || attempt.target_branch;
if (!baseBranch) return;
openTaskForm({
mode: 'subtask',
projectId,
parentTaskAttemptId: attempt.id,
initialBaseBranch: attempt.branch || attempt.target_branch,
initialBaseBranch: baseBranch,
});
};

View File

@@ -30,6 +30,7 @@ interface ImageUploadSectionProps {
readOnly?: boolean;
collapsible?: boolean;
defaultExpanded?: boolean;
hideDropZone?: boolean; // Hide the drag and drop area
className?: string;
}
@@ -64,6 +65,7 @@ export const ImageUploadSection = forwardRef<
readOnly = false,
collapsible = true,
defaultExpanded = false,
hideDropZone = false,
className,
},
ref
@@ -232,8 +234,8 @@ export const ImageUploadSection = forwardRef<
<p className="text-sm text-muted-foreground">No images attached</p>
)}
{/* Drop zone - only show when not read-only */}
{!readOnly && (
{/* Drop zone - only show when not read-only and not hidden */}
{!readOnly && !hideDropZone && (
<div
className={cn(
'border-2 border-dashed rounded-lg p-6 text-center transition-colors',

View File

@@ -3,67 +3,79 @@ import { cn } from '@/lib/utils';
interface AutoExpandingTextareaProps extends React.ComponentProps<'textarea'> {
maxRows?: number;
disableInternalScroll?: boolean;
}
const AutoExpandingTextarea = React.forwardRef<
HTMLTextAreaElement,
AutoExpandingTextareaProps
>(({ className, maxRows = 10, ...props }, ref) => {
const internalRef = React.useRef<HTMLTextAreaElement>(null);
>(
(
{ className, maxRows = 10, disableInternalScroll = false, ...props },
ref
) => {
const internalRef = React.useRef<HTMLTextAreaElement>(null);
// Get the actual ref to use
const textareaRef = ref || internalRef;
// Get the actual ref to use
const textareaRef = ref || internalRef;
const adjustHeight = React.useCallback(() => {
const textarea = (textareaRef as React.RefObject<HTMLTextAreaElement>)
.current;
if (!textarea) return;
const adjustHeight = React.useCallback(() => {
const textarea = (textareaRef as React.RefObject<HTMLTextAreaElement>)
.current;
if (!textarea) return;
// Reset height to auto to get the natural height
textarea.style.height = 'auto';
// Reset height to auto to get the natural height
textarea.style.height = 'auto';
// Calculate line height
const style = window.getComputedStyle(textarea);
const lineHeight = parseInt(style.lineHeight) || 20;
const paddingTop = parseInt(style.paddingTop) || 0;
const paddingBottom = parseInt(style.paddingBottom) || 0;
if (disableInternalScroll) {
// When parent handles scroll, expand to full content height
textarea.style.height = `${textarea.scrollHeight}px`;
} else {
// Calculate line height
const style = window.getComputedStyle(textarea);
const lineHeight = parseInt(style.lineHeight) || 20;
const paddingTop = parseInt(style.paddingTop) || 0;
const paddingBottom = parseInt(style.paddingBottom) || 0;
// Calculate max height based on maxRows
const maxHeight = lineHeight * maxRows + paddingTop + paddingBottom;
// Calculate max height based on maxRows
const maxHeight = lineHeight * maxRows + paddingTop + paddingBottom;
// Set the height to scrollHeight, but cap at maxHeight
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
textarea.style.height = `${newHeight}px`;
}, [maxRows]);
// Adjust height on mount and when content changes
React.useEffect(() => {
adjustHeight();
}, [adjustHeight, props.value]);
// Adjust height on input
const handleInput = React.useCallback(
(e: React.FormEvent<HTMLTextAreaElement>) => {
adjustHeight();
if (props.onInput) {
props.onInput(e);
// Set the height to scrollHeight, but cap at maxHeight
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
textarea.style.height = `${newHeight}px`;
}
},
[adjustHeight, props.onInput]
);
}, [maxRows, disableInternalScroll]);
return (
<textarea
className={cn(
'bg-muted p-0 min-h-[80px] w-full text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50 resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words',
className
)}
ref={textareaRef}
onInput={handleInput}
{...props}
/>
);
});
// Adjust height on mount and when content changes
React.useEffect(() => {
adjustHeight();
}, [adjustHeight, props.value]);
// Adjust height on input
const handleInput = React.useCallback(
(e: React.FormEvent<HTMLTextAreaElement>) => {
adjustHeight();
if (props.onInput) {
props.onInput(e);
}
},
[adjustHeight, props.onInput]
);
return (
<textarea
className={cn(
'bg-muted p-0 min-h-[80px] w-full text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50 resize-none overflow-x-hidden whitespace-pre-wrap break-words',
disableInternalScroll ? 'overflow-hidden' : 'overflow-y-auto',
className
)}
ref={textareaRef}
onInput={handleInput}
{...props}
/>
);
}
);
AutoExpandingTextarea.displayName = 'AutoExpandingTextarea';

View File

@@ -123,7 +123,7 @@ const Dialog = React.forwardRef<
>
{!uncloseable && (
<button
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 z-10"
onClick={() => onOpenChange?.(false)}
>
<X className="h-4 w-4" />

View File

@@ -1,11 +1,26 @@
import { useEffect, useRef, useState, forwardRef } from 'react';
import {
useEffect,
useRef,
useState,
forwardRef,
useLayoutEffect,
useCallback,
} from 'react';
import { createPortal } from 'react-dom';
import { AutoExpandingTextarea } from '@/components/ui/auto-expanding-textarea';
import { projectsApi, tagsApi } from '@/lib/api';
import { Tag as TagIcon, FileText } from 'lucide-react';
import { getCaretClientRect } from '@/lib/caret-position';
import type { SearchResult, Tag } from 'shared/types';
const DROPDOWN_MIN_WIDTH = 320;
const DROPDOWN_MAX_HEIGHT = 320;
const DROPDOWN_MIN_HEIGHT = 120;
const DROPDOWN_VIEWPORT_PADDING = 16;
const DROPDOWN_VIEWPORT_PADDING_TOTAL = DROPDOWN_VIEWPORT_PADDING * 2;
const DROPDOWN_GAP = 4;
interface FileSearchResult extends SearchResult {
name: string;
}
@@ -32,6 +47,7 @@ interface FileSearchTextareaProps {
onPasteFiles?: (files: File[]) => void;
onFocus?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
onBlur?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
disableScroll?: boolean;
}
export const FileSearchTextarea = forwardRef<
@@ -51,6 +67,7 @@ export const FileSearchTextarea = forwardRef<
onPasteFiles,
onFocus,
onBlur,
disableScroll = false,
},
ref
) {
@@ -219,74 +236,141 @@ export const FileSearchTextarea = forwardRef<
};
// Calculate dropdown position relative to textarea
const getDropdownPosition = () => {
if (!textareaRef.current) return { top: 0, left: 0, maxHeight: 240 };
const getDropdownPosition = useCallback(() => {
if (typeof window === 'undefined' || !textareaRef.current) {
return {
top: 0,
left: 0,
maxHeight: DROPDOWN_MAX_HEIGHT,
};
}
const textareaRect = textareaRef.current.getBoundingClientRect();
const dropdownWidth = 320; // Wider for tag content preview
const maxDropdownHeight = 320;
const minDropdownHeight = 120;
const caretRect = getCaretClientRect(textareaRef.current);
const referenceRect =
caretRect ?? textareaRef.current.getBoundingClientRect();
const currentDropdownRect = dropdownRef.current?.getBoundingClientRect();
// Position dropdown below the textarea by default
let finalTop = textareaRect.bottom + 4; // 4px gap
let finalLeft = textareaRect.left;
let maxHeight = maxDropdownHeight;
const availableWidth = Math.max(
window.innerWidth - DROPDOWN_VIEWPORT_PADDING * 2,
0
);
const fallbackWidth =
availableWidth > 0
? Math.min(DROPDOWN_MIN_WIDTH, availableWidth)
: DROPDOWN_MIN_WIDTH;
const measuredWidth =
currentDropdownRect && currentDropdownRect.width > 0
? currentDropdownRect.width
: fallbackWidth;
const dropdownWidth =
availableWidth > 0
? Math.min(Math.max(measuredWidth, fallbackWidth), availableWidth)
: Math.max(measuredWidth, fallbackWidth);
// Position dropdown near the caret by default
let finalTop = referenceRect.bottom + DROPDOWN_GAP;
let finalLeft = referenceRect.left;
let maxHeight = DROPDOWN_MAX_HEIGHT;
// Ensure dropdown doesn't go off the right edge
if (finalLeft + dropdownWidth > window.innerWidth - 16) {
finalLeft = window.innerWidth - dropdownWidth - 16;
if (
finalLeft + dropdownWidth >
window.innerWidth - DROPDOWN_VIEWPORT_PADDING
) {
finalLeft = window.innerWidth - dropdownWidth - DROPDOWN_VIEWPORT_PADDING;
}
// Ensure dropdown doesn't go off the left edge
if (finalLeft < 16) {
finalLeft = 16;
if (finalLeft < DROPDOWN_VIEWPORT_PADDING) {
finalLeft = DROPDOWN_VIEWPORT_PADDING;
}
// Calculate available space below and above textarea
const availableSpaceBelow = window.innerHeight - textareaRect.bottom - 32;
const availableSpaceAbove = textareaRect.top - 32;
// Calculate available space below and above the caret
const availableSpaceBelow =
window.innerHeight - referenceRect.bottom - DROPDOWN_VIEWPORT_PADDING * 2;
const availableSpaceAbove =
referenceRect.top - DROPDOWN_VIEWPORT_PADDING * 2;
// If not enough space below, position above
if (
availableSpaceBelow < minDropdownHeight &&
availableSpaceBelow < DROPDOWN_MIN_HEIGHT &&
availableSpaceAbove > availableSpaceBelow
) {
// Get actual height from rendered dropdown
const actualHeight =
dropdownRef.current?.getBoundingClientRect().height ||
minDropdownHeight;
finalTop = textareaRect.top - actualHeight - 4;
const actualHeight = currentDropdownRect?.height || DROPDOWN_MIN_HEIGHT;
finalTop = referenceRect.top - actualHeight - DROPDOWN_GAP;
maxHeight = Math.min(
maxDropdownHeight,
Math.max(availableSpaceAbove, minDropdownHeight)
DROPDOWN_MAX_HEIGHT,
Math.max(availableSpaceAbove, DROPDOWN_MIN_HEIGHT)
);
} else {
// Position below with available space
maxHeight = Math.min(
maxDropdownHeight,
Math.max(availableSpaceBelow, minDropdownHeight)
DROPDOWN_MAX_HEIGHT,
Math.max(availableSpaceBelow, DROPDOWN_MIN_HEIGHT)
);
}
return { top: finalTop, left: finalLeft, maxHeight };
};
const estimatedHeight =
currentDropdownRect?.height || Math.min(maxHeight, DROPDOWN_MAX_HEIGHT);
const maxTop =
window.innerHeight -
DROPDOWN_VIEWPORT_PADDING -
Math.max(estimatedHeight, DROPDOWN_MIN_HEIGHT);
// Use effect to reposition when dropdown content changes
useEffect(() => {
if (showDropdown && dropdownRef.current) {
// Small delay to ensure content is rendered
setTimeout(() => {
const newPosition = getDropdownPosition();
if (dropdownRef.current) {
dropdownRef.current.style.top = `${newPosition.top}px`;
dropdownRef.current.style.left = `${newPosition.left}px`;
dropdownRef.current.style.maxHeight = `${newPosition.maxHeight}px`;
}
}, 0);
if (finalTop > maxTop) {
finalTop = Math.max(DROPDOWN_VIEWPORT_PADDING, maxTop);
}
}, [searchResults.length, showDropdown]);
const dropdownPosition = getDropdownPosition();
if (finalTop < DROPDOWN_VIEWPORT_PADDING) {
finalTop = DROPDOWN_VIEWPORT_PADDING;
}
return {
top: finalTop,
left: finalLeft,
maxHeight,
};
}, [searchQuery, value]);
const [dropdownPosition, setDropdownPosition] = useState(() =>
getDropdownPosition()
);
// Keep dropdown positioned near the caret and within viewport bounds
useLayoutEffect(() => {
if (!showDropdown) return;
const updatePosition = () => {
const newPosition = getDropdownPosition();
setDropdownPosition((prev) => {
if (
prev.top === newPosition.top &&
prev.left === newPosition.left &&
prev.maxHeight === newPosition.maxHeight
) {
return prev;
}
return newPosition;
});
};
updatePosition();
let frameId = requestAnimationFrame(updatePosition);
const scheduleUpdate = () => {
cancelAnimationFrame(frameId);
frameId = requestAnimationFrame(updatePosition);
};
window.addEventListener('resize', scheduleUpdate);
window.addEventListener('scroll', scheduleUpdate, true);
return () => {
cancelAnimationFrame(frameId);
window.removeEventListener('resize', scheduleUpdate);
window.removeEventListener('scroll', scheduleUpdate, true);
};
}, [showDropdown, searchResults.length, getDropdownPosition]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Handle dropdown navigation first
@@ -352,6 +436,7 @@ export const FileSearchTextarea = forwardRef<
onPaste={handlePaste}
onFocus={onFocus}
onBlur={onBlur}
disableInternalScroll={disableScroll}
/>
{showDropdown &&
@@ -363,7 +448,8 @@ export const FileSearchTextarea = forwardRef<
top: dropdownPosition.top,
left: dropdownPosition.left,
maxHeight: dropdownPosition.maxHeight,
minWidth: '320px',
minWidth: `min(${DROPDOWN_MIN_WIDTH}px, calc(100vw - ${DROPDOWN_VIEWPORT_PADDING_TOTAL}px))`,
maxWidth: `calc(100vw - ${DROPDOWN_VIEWPORT_PADDING_TOTAL}px)`,
zIndex: 10000, // Higher than dialog z-[9999]
}}
>

View File

@@ -1,6 +1,11 @@
export { useBranchStatus } from './useBranchStatus';
export { useAttemptExecution } from './useAttemptExecution';
export { useOpenInEditor } from './useOpenInEditor';
export { useProjectBranches } from './useProjectBranches';
export { useTaskAttempt } from './useTaskAttempt';
export { useTaskImages } from './useTaskImages';
export { useImageUpload } from './useImageUpload';
export { useTaskMutations } from './useTaskMutations';
export { useDevServer } from './useDevServer';
export { useRebase } from './useRebase';
export { useChangeTargetBranch } from './useChangeTargetBranch';

View File

@@ -0,0 +1,26 @@
import { useCallback } from 'react';
import { imagesApi } from '@/lib/api';
import type { ImageResponse } from 'shared/types';
export function useImageUpload() {
const upload = useCallback(async (file: File): Promise<ImageResponse> => {
return imagesApi.upload(file);
}, []);
const uploadForTask = useCallback(
async (taskId: string, file: File): Promise<ImageResponse> => {
return imagesApi.uploadForTask(taskId, file);
},
[]
);
const deleteImage = useCallback(async (imageId: string): Promise<void> => {
return imagesApi.delete(imageId);
}, []);
return {
upload,
uploadForTask,
deleteImage,
};
}

View File

@@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { projectsApi } from '@/lib/api';
import type { GitBranch } from 'shared/types';
export function useProjectBranches(projectId?: string) {
return useQuery<GitBranch[]>({
queryKey: ['projectBranches', projectId],
queryFn: () => projectsApi.getBranches(projectId!),
enabled: !!projectId,
});
}

View File

@@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { imagesApi } from '@/lib/api';
import type { ImageResponse } from 'shared/types';
export function useTaskImages(taskId?: string) {
return useQuery<ImageResponse[]>({
queryKey: ['taskImages', taskId],
queryFn: () => imagesApi.getTaskImages(taskId!),
enabled: !!taskId,
});
}

View File

@@ -373,5 +373,33 @@
"next": "Next",
"finish": "Finish"
}
},
"taskFormDialog": {
"createTitle": "Create New Task",
"editTitle": "Edit Task",
"titlePlaceholder": "Task title",
"descriptionPlaceholder": "Add more details (optional). Type @ to search files.",
"statusLabel": "Status",
"statusOptions": {
"todo": "To Do",
"inprogress": "In Progress",
"inreview": "In Review",
"done": "Done",
"cancelled": "Cancelled"
},
"startLabel": "Start",
"attachImage": "Attach image",
"dropImagesHere": "Drop images here",
"updating": "Updating...",
"updateTask": "Update Task",
"starting": "Starting...",
"creating": "Creating...",
"create": "Create",
"discardDialog": {
"title": "Discard unsaved changes?",
"description": "You have unsaved changes. Are you sure you want to discard them?",
"continueEditing": "Continue Editing",
"discardChanges": "Discard Changes"
}
}
}

View File

@@ -373,5 +373,33 @@
"next": "Siguiente",
"finish": "Finalizar"
}
},
"taskFormDialog": {
"createTitle": "Crear Nueva Tarea",
"editTitle": "Editar Tarea",
"titlePlaceholder": "Título de la tarea",
"descriptionPlaceholder": "Agrega más detalles (opcional). Escribe @ para buscar archivos.",
"statusLabel": "Estado",
"statusOptions": {
"todo": "Por Hacer",
"inprogress": "En Progreso",
"inreview": "En Revisión",
"done": "Completado",
"cancelled": "Cancelado"
},
"startLabel": "Iniciar",
"attachImage": "Adjuntar imagen",
"dropImagesHere": "Suelta las imágenes aquí",
"updating": "Actualizando...",
"updateTask": "Actualizar Tarea",
"starting": "Iniciando...",
"creating": "Creando...",
"create": "Crear",
"discardDialog": {
"title": "¿Descartar cambios sin guardar?",
"description": "Tienes cambios sin guardar. ¿Estás seguro de que deseas descartarlos?",
"continueEditing": "Continuar Editando",
"discardChanges": "Descartar Cambios"
}
}
}

View File

@@ -373,5 +373,33 @@
"next": "次へ",
"finish": "完了"
}
},
"taskFormDialog": {
"createTitle": "新規タスクを作成",
"editTitle": "タスクを編集",
"titlePlaceholder": "タスクのタイトル",
"descriptionPlaceholder": "詳細を追加(オプション)。@でファイルを検索できます。",
"statusLabel": "ステータス",
"statusOptions": {
"todo": "未着手",
"inprogress": "進行中",
"inreview": "レビュー中",
"done": "完了",
"cancelled": "キャンセル"
},
"startLabel": "開始",
"attachImage": "画像を添付",
"dropImagesHere": "画像をここにドロップ",
"updating": "更新中...",
"updateTask": "タスクを更新",
"starting": "開始中...",
"creating": "作成中...",
"create": "作成",
"discardDialog": {
"title": "未保存の変更を破棄しますか?",
"description": "未保存の変更があります。本当に破棄してもよろしいですか?",
"continueEditing": "編集を続ける",
"discardChanges": "変更を破棄"
}
}
}

View File

@@ -373,5 +373,33 @@
"next": "다음",
"finish": "완료"
}
},
"taskFormDialog": {
"createTitle": "새 작업 만들기",
"editTitle": "작업 수정",
"titlePlaceholder": "작업 제목",
"descriptionPlaceholder": "세부 정보 추가 (선택 사항). @를 입력하여 파일을 검색합니다.",
"statusLabel": "상태",
"statusOptions": {
"todo": "할 일",
"inprogress": "진행 중",
"inreview": "검토 중",
"done": "완료",
"cancelled": "취소됨"
},
"startLabel": "시작",
"attachImage": "이미지 첨부",
"dropImagesHere": "여기에 이미지를 드롭하세요",
"updating": "업데이트 중...",
"updateTask": "작업 업데이트",
"starting": "시작 중...",
"creating": "만드는 중...",
"create": "만들기",
"discardDialog": {
"title": "저장하지 않은 변경사항을 버리시겠습니까?",
"description": "저장하지 않은 변경사항이 있습니다. 정말 버리시겠습니까?",
"continueEditing": "계속 수정",
"discardChanges": "변경사항 버리기"
}
}
}

View File

@@ -1,6 +1,7 @@
export enum Scope {
GLOBAL = 'global',
DIALOG = 'dialog',
CONFIRMATION = 'confirmation',
KANBAN = 'kanban',
PROJECTS = 'projects',
SETTINGS = 'settings',
@@ -42,6 +43,13 @@ export interface KeyBinding {
export const keyBindings: KeyBinding[] = [
// Exit/Close actions
{
action: Action.EXIT,
keys: 'esc',
scopes: [Scope.CONFIRMATION],
description: 'Close confirmation dialog',
group: 'Dialog',
},
{
action: Action.EXIT,
keys: 'esc',

View File

@@ -0,0 +1,73 @@
const CARET_PROBE_CHARACTER = '\u200b';
const mirrorStyleProperties = [
'boxSizing',
'fontFamily',
'fontSize',
'fontStyle',
'fontWeight',
'letterSpacing',
'lineHeight',
'paddingTop',
'paddingRight',
'paddingBottom',
'paddingLeft',
'textAlign',
'textTransform',
'borderTopWidth',
'borderRightWidth',
'borderBottomWidth',
'borderLeftWidth',
'borderTopStyle',
'borderRightStyle',
'borderBottomStyle',
'borderLeftStyle',
] as const;
type MirrorStyleProperty = (typeof mirrorStyleProperties)[number];
export const getCaretClientRect = (
textarea: HTMLTextAreaElement,
targetIndex?: number
) => {
if (typeof window === 'undefined') return null;
const selectionIndex =
typeof targetIndex === 'number'
? Math.min(Math.max(targetIndex, 0), textarea.value.length)
: (textarea.selectionEnd ?? textarea.value.length);
const textBeforeCaret = textarea.value.slice(0, selectionIndex);
const textareaRect = textarea.getBoundingClientRect();
const computedStyle = window.getComputedStyle(textarea);
const mirror = document.createElement('div');
mirror.setAttribute('data-caret-mirror', 'true');
mirror.style.position = 'absolute';
mirror.style.top = `${textareaRect.top + window.scrollY}px`;
mirror.style.left = `${textareaRect.left + window.scrollX}px`;
mirror.style.visibility = 'hidden';
mirror.style.whiteSpace = 'pre-wrap';
mirror.style.wordBreak = 'break-word';
mirror.style.overflow = 'hidden';
mirror.style.width = `${textareaRect.width}px`;
mirrorStyleProperties.forEach((property: MirrorStyleProperty) => {
const value = computedStyle[property];
if (value) {
mirror.style[property] = value;
}
});
mirror.textContent = textBeforeCaret;
const probe = document.createElement('span');
probe.textContent = CARET_PROBE_CHARACTER;
mirror.appendChild(probe);
document.body.appendChild(mirror);
const caretRect = probe.getBoundingClientRect();
document.body.removeChild(mirror);
return caretRect;
};

View File

@@ -133,6 +133,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<ClickToComponent />
<VibeKanbanWebCompanion />
<App />
{/*<TanStackDevtools plugins={[FormDevtoolsPlugin()]} />*/}
{/* <ReactQueryDevtools initialIsOpen={false} /> */}
</Sentry.ErrorBoundary>
</PostHogProvider>

View File

@@ -167,7 +167,7 @@ export function ProjectTasks() {
const handleCreateTask = useCallback(() => {
if (projectId) {
openTaskForm({ projectId });
openTaskForm({ mode: 'create', projectId });
}
}, [projectId]);
const { query: searchQuery, focusInput } = useSearch();