feat(KB-640): add role editing UI and restore KB task prefix
- Add inline role editing to AgentListModal with clickable role icons - Add handleRoleChange and handleRoleKeyDown handlers for role updates - Persist view toggle (board/list) with localStorage alongside role editing - Restore task prefix from FN back to KB for generated task IDs - Restore branch naming from fusion/ back to kb/ for task branches - Update branch name references in executor, merger, scheduler, and CLI - Add touch gesture detection support to TaskCard component - Update worktree grouping labels to use KB prefix
This commit is contained in:
@@ -71,7 +71,7 @@ export function getMergeStrategy(settings: Pick<Settings, "mergeStrategy">): Non
|
||||
}
|
||||
|
||||
export function getTaskBranchName(taskId: string): string {
|
||||
return `fusion/${taskId.toLowerCase()}`;
|
||||
return `kb/${taskId.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function buildPullRequestTitle(task: Pick<TaskDetail, "id" | "title">): string {
|
||||
|
||||
@@ -1056,7 +1056,7 @@ export async function runTaskPrCreate(id: string, options: PrCreateOptions = {})
|
||||
}
|
||||
|
||||
// Build branch name
|
||||
const branchName = `fusion/${id.toLowerCase()}`;
|
||||
const branchName = `kb/${id.toLowerCase()}`;
|
||||
|
||||
// Build PR title
|
||||
let title: string;
|
||||
|
||||
@@ -556,7 +556,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
const id = this.db.transaction(() => {
|
||||
const row = this.db.prepare("SELECT nextId, settings FROM config WHERE id = 1").get() as any;
|
||||
const settings = fromJson<Settings>(row.settings);
|
||||
const prefix = settings?.taskPrefix || "FN";
|
||||
const prefix = settings?.taskPrefix || "KB";
|
||||
const nextId = row.nextId || 1;
|
||||
const taskId = `${prefix}-${String(nextId).padStart(3, "0")}`;
|
||||
this.db.prepare("UPDATE config SET nextId = ? WHERE id = 1").run(nextId + 1);
|
||||
@@ -1284,7 +1284,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
);
|
||||
}
|
||||
|
||||
const branch = `fusion/${id.toLowerCase()}`;
|
||||
const branch = `kb/${id.toLowerCase()}`;
|
||||
const worktreePath = task.worktree;
|
||||
const result: MergeResult = {
|
||||
task,
|
||||
|
||||
@@ -580,8 +580,8 @@ export interface ProjectSettings {
|
||||
* - "task-title": Use a slugified version of the task title (e.g., fix-login-bug)
|
||||
* Default: "random". */
|
||||
worktreeNaming?: "random" | "task-id" | "task-title";
|
||||
/** Prefix for generated task IDs (e.g. `"FN"` produces `FN-001`).
|
||||
* Defaults to `"FN"`. Only affects new tasks — existing tasks retain
|
||||
/** Prefix for generated task IDs (e.g. `"KB"` produces `KB-001`).
|
||||
* Defaults to `"KB"`. Only affects new tasks — existing tasks retain
|
||||
* their original IDs. */
|
||||
taskPrefix?: string;
|
||||
/** When true, merge commit messages include the task ID as the conventional
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
|
||||
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Maximize2 } from "lucide-react";
|
||||
import { Link, Clock, Layers, Pencil, ChevronDown, Folder } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column, PrInfo, IssueInfo } from "@fusion/core";
|
||||
import { fetchTaskDetail, uploadAttachment } from "../api";
|
||||
import { GitHubBadge } from "./GitHubBadge";
|
||||
@@ -138,10 +138,20 @@ function TaskCardComponent({
|
||||
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const touchOpenHandledRef = useRef(false);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const [isInViewport, setIsInViewport] = useState(false);
|
||||
const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket();
|
||||
|
||||
// Touch gesture detection refs
|
||||
const touchStartPosRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
||||
const hasTouchMovedRef = useRef(false);
|
||||
|
||||
const isInteractiveTarget = useCallback((target: EventTarget | null): boolean => {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
return !!target.closest("button, a, input, textarea, select, label, [role='button']");
|
||||
}, []);
|
||||
|
||||
// Reset edit state when task changes
|
||||
useEffect(() => {
|
||||
setEditTitle(task.title || "");
|
||||
@@ -232,10 +242,58 @@ function TaskCardComponent({
|
||||
}
|
||||
}, [task.id, onOpenDetail, addToast, isEditing]);
|
||||
|
||||
const handleExpandClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const handleCardClick = useCallback((e: React.MouseEvent) => {
|
||||
if (touchOpenHandledRef.current) {
|
||||
touchOpenHandledRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (isInteractiveTarget(e.target)) return;
|
||||
void handleClick();
|
||||
}, [handleClick]);
|
||||
}, [handleClick, isInteractiveTarget]);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
|
||||
touchStartPosRef.current = { x: touch.clientX, y: touch.clientY, time: Date.now() };
|
||||
hasTouchMovedRef.current = false;
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
if (!touchStartPosRef.current) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
|
||||
const dx = Math.abs(touch.clientX - touchStartPosRef.current.x);
|
||||
const dy = Math.abs(touch.clientY - touchStartPosRef.current.y);
|
||||
|
||||
// If moved beyond threshold, mark as moved (scrolling/dragging)
|
||||
if (dx > TOUCH_MOVE_THRESHOLD || dy > TOUCH_MOVE_THRESHOLD) {
|
||||
hasTouchMovedRef.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
|
||||
if (isInteractiveTarget(e.target)) return;
|
||||
|
||||
// Check if this was a valid tap (not a scroll)
|
||||
if (!touchStartPosRef.current) return;
|
||||
|
||||
const touchDuration = Date.now() - touchStartPosRef.current.time;
|
||||
const isQuickTap = touchDuration < TOUCH_TAP_MAX_DURATION;
|
||||
const isStationary = !hasTouchMovedRef.current;
|
||||
|
||||
// Only open modal for quick taps that didn't move significantly
|
||||
if (isQuickTap && isStationary) {
|
||||
touchOpenHandledRef.current = true;
|
||||
void handleClick();
|
||||
}
|
||||
|
||||
// Reset touch tracking
|
||||
touchStartPosRef.current = null;
|
||||
hasTouchMovedRef.current = false;
|
||||
}, [handleClick, isInteractiveTarget]);
|
||||
|
||||
const handleDepClick = useCallback(async (e: React.MouseEvent, depId: string) => {
|
||||
e.stopPropagation(); // Prevent card click
|
||||
@@ -502,6 +560,10 @@ function TaskCardComponent({
|
||||
onDragOver={handleFileDragOver}
|
||||
onDragLeave={handleFileDragLeave}
|
||||
onDrop={handleFileDrop}
|
||||
onClick={handleCardClick}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
<div className="card-header">
|
||||
@@ -532,14 +594,6 @@ function TaskCardComponent({
|
||||
/>
|
||||
)}
|
||||
<div className="card-header-actions">
|
||||
<button
|
||||
className="card-expand-btn"
|
||||
onClick={handleExpandClick}
|
||||
title="Open task details"
|
||||
aria-label="Open task details"
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button
|
||||
className="card-edit-btn"
|
||||
@@ -678,5 +732,8 @@ function TaskCardComponent({
|
||||
);
|
||||
}
|
||||
|
||||
const TOUCH_MOVE_THRESHOLD = 10; // pixels
|
||||
const TOUCH_TAP_MAX_DURATION = 300; // milliseconds
|
||||
|
||||
export const TaskCard = memo(TaskCardComponent, areTaskCardPropsEqual);
|
||||
TaskCard.displayName = "TaskCard";
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface WorktreeGroupData {
|
||||
|
||||
/**
|
||||
* Extract a clean display name from a worktree path.
|
||||
* e.g. ".worktrees/FN-001" → "FN-001", "/path/to/fusion/fn-001" → "fn-001"
|
||||
* e.g. ".worktrees/KB-001" → "KB-001", "/path/to/kb/kb-001" → "kb-001"
|
||||
*/
|
||||
export function getWorktreeLabel(worktreePath: string): string {
|
||||
// Take the last segment of the path
|
||||
|
||||
@@ -3316,7 +3316,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
|
||||
// Determine branch name from task
|
||||
const branchName = `fusion/${task.id.toLowerCase()}`;
|
||||
const branchName = `kb/${task.id.toLowerCase()}`;
|
||||
|
||||
// Get owner/repo from git remote or GITHUB_REPOSITORY env
|
||||
let owner: string;
|
||||
|
||||
@@ -350,7 +350,7 @@ export class TaskExecutor {
|
||||
* than being named after the task ID. This decouples directory names from
|
||||
* tasks, enabling worktree reuse across dependency chains. When resuming
|
||||
* a task that already has `task.worktree` set, the existing path is used
|
||||
* as-is. Branches remain task-scoped (`fusion/{task-id}`).
|
||||
* as-is. Branches remain task-scoped (`kb/{task-id}`).
|
||||
*/
|
||||
async execute(task: Task): Promise<void> {
|
||||
if (this.executing.has(task.id)) return;
|
||||
@@ -399,7 +399,7 @@ export class TaskExecutor {
|
||||
}
|
||||
|
||||
// Create or reuse worktree — try pool first when recycling is enabled
|
||||
const branchName = `fusion/${task.id.toLowerCase()}`;
|
||||
const branchName = `kb/${task.id.toLowerCase()}`;
|
||||
// Use generateWorktreeName for human-friendly directory names (adjective-noun pattern)
|
||||
// instead of task.id, so worktrees are named like ".worktrees/swift-falcon"
|
||||
let isResume = existsSync(worktreePath);
|
||||
@@ -1049,7 +1049,7 @@ export class TaskExecutor {
|
||||
}
|
||||
|
||||
// Delete the branch
|
||||
const branch = `fusion/${taskId.toLowerCase()}`;
|
||||
const branch = `kb/${taskId.toLowerCase()}`;
|
||||
try {
|
||||
execSync(`git branch -D "${branch}"`, { cwd: this.rootDir, stdio: "pipe" });
|
||||
} catch {
|
||||
@@ -1070,9 +1070,9 @@ export class TaskExecutor {
|
||||
/**
|
||||
* Create a git worktree at `path` on a new branch.
|
||||
*
|
||||
* @param branch — Branch name (e.g., `fusion/fn-042`)
|
||||
* @param branch — Branch name (e.g., `kb/kb-042`)
|
||||
* @param path — Absolute worktree directory path
|
||||
* @param startPoint — Optional git ref to branch from (e.g., `fusion/fn-041`).
|
||||
* @param startPoint — Optional git ref to branch from (e.g., `kb/kb-041`).
|
||||
* When provided, the worktree starts from that ref instead of HEAD.
|
||||
*/
|
||||
/**
|
||||
@@ -1268,7 +1268,7 @@ If issues are found that need attention, describe them clearly.`;
|
||||
* Create a git worktree with automatic recovery from conflicts.
|
||||
* Implements retry logic with exponential backoff for transient failures.
|
||||
*
|
||||
* @param branch - The branch name to create (e.g., "fusion/fn-123")
|
||||
* @param branch - The branch name to create (e.g., "kb/kb-123")
|
||||
* @param path - The desired worktree path
|
||||
* @param taskId - The task ID for logging
|
||||
* @param startPoint - Optional base branch/commit for new branch
|
||||
|
||||
@@ -538,7 +538,7 @@ export async function aiMergeTask(
|
||||
);
|
||||
}
|
||||
|
||||
const branch = `fusion/${taskId.toLowerCase()}`;
|
||||
const branch = `kb/${taskId.toLowerCase()}`;
|
||||
const worktreePath = task.worktree;
|
||||
const result: MergeResult = {
|
||||
task,
|
||||
|
||||
@@ -253,7 +253,7 @@ export class Scheduler {
|
||||
for (const depId of task.dependencies) {
|
||||
const dep = allTasks.find((t) => t.id === depId);
|
||||
if (dep && dep.column === "in-review" && dep.worktree) {
|
||||
return `fusion/${dep.id.toLowerCase()}`;
|
||||
return `kb/${dep.id.toLowerCase()}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ export class Scheduler {
|
||||
if (task.blockedBy) {
|
||||
const blocker = allTasks.find((t) => t.id === task.blockedBy);
|
||||
if (blocker && blocker.column === "in-review" && blocker.worktree) {
|
||||
return `fusion/${blocker.id.toLowerCase()}`;
|
||||
return `kb/${blocker.id.toLowerCase()}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,8 +111,8 @@ export class WorktreePool {
|
||||
* 3. `git checkout -B <branchName> <startPoint>` — create/reset branch from start point
|
||||
*
|
||||
* @param worktreePath — Absolute path to the recycled worktree
|
||||
* @param branchName — Branch name for the new task (e.g., `fusion/fn-042`)
|
||||
* @param startPoint — Git ref to branch from (e.g., `fusion/fn-041`). Defaults to `main`.
|
||||
* @param branchName — Branch name for the new task (e.g., `kb/kb-042`)
|
||||
* @param startPoint — Git ref to branch from (e.g., `kb/kb-041`). Defaults to `main`.
|
||||
*/
|
||||
prepareForTask(worktreePath: string, branchName: string, startPoint?: string): void {
|
||||
// Clean tracked modifications
|
||||
|
||||
Reference in New Issue
Block a user