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:
gsxdsm
2026-03-31 19:44:36 -07:00
parent 9f0a6413d8
commit ada014213a
11 changed files with 88 additions and 31 deletions

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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,

View File

@@ -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

View File

@@ -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";

View File

@@ -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

View File

@@ -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;

View File

@@ -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

View File

@@ -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,

View File

@@ -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()}`;
}
}

View File

@@ -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