feat: add task owner management and update CLI commands

- Incremented TOOL_VERSION to 9 for the CLI tool.
- Introduced setTaskOwner function to manage task ownership, allowing assignment and clearing of owners.
- Updated CLI commands to include task owner management options in help documentation.
- Enhanced Kanban components to support a compact view for task cards.
- Improved member color mapping to ensure unique colors for team members.
- Added asynchronous execution support in teamctl tests for better concurrency handling.
This commit is contained in:
iliya 2026-02-26 18:32:48 +02:00
parent c99a9cfc48
commit d096b6b19a
9 changed files with 1840 additions and 317 deletions

View file

@ -6,7 +6,7 @@ import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite';
const TOOL_FILE_NAME = 'teamctl.js';
const TOOL_VERSION = 8;
const TOOL_VERSION = 9;
function buildTeamCtlScript(): string {
const script = String.raw`#!/usr/bin/env node
@ -201,6 +201,17 @@ function setTaskStatus(paths, taskId, status) {
writeTask(taskPath, task);
}
function setTaskOwner(paths, taskId, owner) {
const { taskPath, task } = readTask(paths, taskId);
if (owner) {
task.owner = owner;
} else {
delete task.owner;
}
writeTask(taskPath, task);
return task;
}
function addTaskComment(paths, taskId, flags) {
var text = typeof flags.text === 'string' ? flags.text.trim() : '';
if (!text) die('Missing --text');
@ -655,6 +666,7 @@ function printHelp() {
' node teamctl.js task complete <id> [--team <team>]',
' node teamctl.js task start <id> [--team <team>]',
' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--status pending|in_progress|completed|deleted] [--notify --from "member"] [--team <team>]',
' node teamctl.js task set-owner <id> <member|clear> [--notify --from "member"] [--team <team>]',
' node teamctl.js task comment <id> --text "..." [--from "member"] [--team <team>]',
' node teamctl.js task set-clarification <id> <lead|user|clear> [--from "member"] [--team <team>]',
' node teamctl.js task briefing --for <member-name> [--team <team>]',
@ -791,6 +803,37 @@ async function main() {
process.stdout.write('OK task #' + String(id) + ' needsClarification=' + (val === 'clear' ? 'cleared' : String(val)) + '\n');
return;
}
if (action === 'set-owner' || action === 'assign') {
const id = rest[0] || args.flags.id;
const owner = rest[1] || args.flags.owner;
if (!id) die('Usage: task set-owner <id> <member|clear>');
if (!owner) die('Usage: task set-owner <id> <member|clear>');
const effectiveOwner = owner === 'clear' || owner === 'none' ? null : String(owner);
const task = setTaskOwner(paths, String(id), effectiveOwner);
process.stdout.write('OK task #' + String(id) + ' owner=' + (effectiveOwner || 'cleared') + '\n');
const notify = args.flags.notify === true;
if (notify && effectiveOwner) {
const from = typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : inferLeadName(paths);
const parts = ['Task assigned to you: #' + String(task.id) + ' "' + String(task.subject) + '".'];
if (task.description && task.description !== task.subject) {
parts.push('\nDescription:\n' + String(task.description).slice(0, 500));
}
parts.push(
'\n' + ${JSON.stringify(AGENT_BLOCK_OPEN)},
'Update task status using:',
'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task start ' + String(task.id),
'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task complete ' + String(task.id),
${JSON.stringify(AGENT_BLOCK_CLOSE)}
);
sendInboxMessage(paths, teamName, {
to: effectiveOwner,
text: parts.join('\n'),
summary: 'Task #' + String(task.id) + ' assigned',
from,
});
}
return;
}
if (action === 'briefing') {
taskBriefing(paths, teamName, args.flags);
return;

View file

@ -340,6 +340,8 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
``,
`Task board operations — use teamctl.js via Bash:`,
`- Create task: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --description "..." --owner "<actual-member-name>" --notify --from "${leadName}"`,
`- Assign/reassign owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner <id> <member-name> --notify --from "${leadName}"`,
`- Clear owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner <id> clear`,
`- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-status <id> <pending|in_progress|completed|deleted>`,
``,
`Notification policy:`,

View file

@ -149,6 +149,7 @@ interface SortableKanbanTaskCardProps {
columnId: KanbanColumnId;
teamName: string;
kanbanState: KanbanState;
compact?: boolean;
taskMap: Map<string, TeamTask>;
members: ResolvedTeamMember[];
onRequestReview: (taskId: string) => void;
@ -169,6 +170,7 @@ const SortableKanbanTaskCard = ({
columnId,
teamName,
kanbanState,
compact,
taskMap,
members,
onRequestReview,
@ -203,6 +205,7 @@ const SortableKanbanTaskCard = ({
columnId={columnId}
kanbanTaskState={kanbanState.tasks[task.id]}
hasReviewers={kanbanState.reviewers.length > 0}
compact={compact}
taskMap={taskMap}
members={members}
onRequestReview={onRequestReview}
@ -303,7 +306,11 @@ export const KanbanBoard = ({
[onColumnOrderChange, groupedOrdered]
);
const renderCards = (columnId: KanbanColumnId, columnTasks: TeamTask[]): React.JSX.Element => {
const renderCards = (
columnId: KanbanColumnId,
columnTasks: TeamTask[],
compact?: boolean
): React.JSX.Element => {
const addHandler =
onAddTask && columnId === 'todo'
? () => onAddTask(false)
@ -343,6 +350,7 @@ export const KanbanBoard = ({
columnId={columnId}
teamName={teamName}
kanbanState={kanbanState}
compact={compact}
taskMap={taskMap}
members={members}
onRequestReview={onRequestReview}
@ -373,6 +381,7 @@ export const KanbanBoard = ({
columnId={columnId}
kanbanTaskState={kanbanState.tasks[task.id]}
hasReviewers={kanbanState.reviewers.length > 0}
compact={compact}
taskMap={taskMap}
members={members}
onRequestReview={onRequestReview}
@ -497,7 +506,7 @@ export const KanbanBoard = ({
headerBg={accent.headerBg}
bodyBg={accent.bodyBg}
>
{renderCards(column.id, columnTasks)}
{renderCards(column.id, columnTasks, true)}
</KanbanColumn>
</div>
);

View file

@ -38,7 +38,7 @@ export const KanbanColumn = ({
{count}
</Badge>
</header>
<div className="flex max-h-[480px] flex-col gap-2 overflow-auto p-2">{children}</div>
<div className="flex max-h-[480px] flex-col overflow-auto p-2">{children}</div>
</section>
);
};

View file

@ -27,6 +27,7 @@ interface KanbanTaskCardProps {
columnId: KanbanColumnId;
kanbanTaskState?: KanbanTaskState;
hasReviewers: boolean;
compact?: boolean;
taskMap: Map<string, TeamTask>;
members: ResolvedTeamMember[];
onRequestReview: (taskId: string) => void;
@ -134,6 +135,7 @@ export const KanbanTaskCard = ({
columnId,
kanbanTaskState: _kanbanTaskState,
hasReviewers,
compact,
taskMap,
members,
onRequestReview,
@ -186,26 +188,35 @@ export const KanbanTaskCard = ({
}
}}
>
<div className="mb-2 flex items-center gap-1">
<Badge variant="secondary" className="shrink-0 px-1 py-0 text-[10px] font-normal">
#{task.id}
</Badge>
{task.owner ? <MemberBadge name={task.owner} color={colorMap.get(task.owner)} /> : null}
{task.needsClarification ? (
<span
className={`inline-flex shrink-0 items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
task.needsClarification === 'user'
? 'bg-red-500/15 text-red-400'
: 'bg-blue-500/15 text-blue-400'
}`}
>
<HelpCircle size={10} />
{task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'}
</span>
) : null}
<h5 className="min-w-0 truncate text-sm font-medium text-[var(--color-text)]">
{task.subject}
</h5>
<div className="mb-2">
<div className="flex items-center gap-1">
<Badge variant="secondary" className="shrink-0 px-1 py-0 text-[10px] font-normal">
#{task.id}
</Badge>
{task.owner ? <MemberBadge name={task.owner} color={colorMap.get(task.owner)} /> : null}
{task.needsClarification ? (
<span
className={`inline-flex shrink-0 items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
task.needsClarification === 'user'
? 'bg-red-500/15 text-red-400'
: 'bg-blue-500/15 text-blue-400'
}`}
>
<HelpCircle size={10} />
{task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'}
</span>
) : null}
{!compact && (
<h5 className="min-w-0 truncate text-sm font-medium text-[var(--color-text)]">
{task.subject}
</h5>
)}
</div>
{compact && (
<h5 className="mt-1 truncate text-sm font-medium text-[var(--color-text)]">
{task.subject}
</h5>
)}
</div>
{hasBlockedBy ? (
@ -332,12 +343,14 @@ export const KanbanTaskCard = ({
<Button
variant="outline"
size="sm"
className="gap-1 border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
aria-label={`Approve task ${task.id}`}
onClick={(e) => {
e.stopPropagation();
onApprove(task.id);
}}
>
<CheckCircle2 size={12} />
Approve
</Button>
<Button

View file

@ -69,7 +69,7 @@ export const MemberList = ({
};
return (
<div className="flex flex-col gap-0.5">
<div className="flex flex-col">
{activeMembers.map((member) => renderCard(member, false))}
{removedMembers.length > 0 && (
<>

View file

@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { undo } from '@codemirror/commands';
import { goToNextChunk, rejectChunk } from '@codemirror/merge';
import { isElectronMode } from '@renderer/api';
import { useContinuousScrollNav } from '@renderer/hooks/useContinuousScrollNav';
@ -89,6 +90,8 @@ export const ChangeReviewDialog = ({
// EditorView map for all visible file editors
const editorViewMapRef = useRef(new Map<string, EditorView>());
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Last focused CM editor — for Cmd+Z outside editor
const lastFocusedEditorRef = useRef<EditorView | null>(null);
// Proxy ref for useDiffNavigation (points to active file's editor)
const activeEditorViewRef = useRef<EditorView | null>(null);
@ -349,14 +352,50 @@ export const ChangeReviewDialog = ({
return () => document.removeEventListener('keydown', handler);
}, [open, onOpenChange]);
// Cmd+Z for undo bulk review (when not inside a CM editor)
// Track last focused CM editor for Cmd+Z outside editor
useEffect(() => {
if (!open) return;
const handleFocusIn = (e: FocusEvent): void => {
const target = e.target as Element | null;
if (!target?.closest?.('.cm-editor')) return;
for (const view of editorViewMapRef.current.values()) {
if (view.dom.contains(target)) {
lastFocusedEditorRef.current = view;
return;
}
}
};
document.addEventListener('focusin', handleFocusIn);
return () => {
document.removeEventListener('focusin', handleFocusIn);
lastFocusedEditorRef.current = null;
};
}, [open]);
// Cmd+Z: undo in last focused editor, or fall back to bulk review undo
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent): void => {
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
// Don't intercept if focus is inside a CM editor — let CM handle its own undo
if (document.activeElement?.closest('.cm-editor')) return;
// Don't intercept native undo in input/textarea
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
// Try to undo in the last focused CM editor
const lastView = lastFocusedEditorRef.current;
if (lastView?.dom.isConnected) {
e.preventDefault();
e.stopPropagation();
undo(lastView);
return;
}
// Fall back to bulk undo (Accept All / Reject All)
if (useStore.getState().reviewUndoStack.length > 0) {
e.preventDefault();
e.stopPropagation();

View file

@ -77,24 +77,34 @@ interface MemberColorInput {
/**
* Build a consistent namecolorName map for all members.
* Replicates the same index-based assignment as MemberList so that
* every component resolves the same color for a given member.
* Also maps "user" to the team-lead's color.
* Deduplicates colors: first member (alphabetically) keeps its stored color,
* subsequent collisions get the next unused palette color.
* Also maps "user" to a reserved color.
*/
export function buildMemberColorMap(members: MemberColorInput[]): Map<string, string> {
const map = new Map<string, string>();
const active = members.filter((m) => !m.removedAt);
const removed = members.filter((m) => m.removedAt);
const usedColors = new Set<string>();
for (let i = 0; i < active.length; i++) {
map.set(active[i].name, active[i].color ?? getMemberColor(i));
let nextFallback = 0;
for (const member of active) {
let color = member.color;
if (!color || usedColors.has(color)) {
while (usedColors.has(getMemberColor(nextFallback))) {
nextFallback++;
}
color = getMemberColor(nextFallback);
nextFallback++;
}
map.set(member.name, color);
usedColors.add(color);
}
for (let i = 0; i < removed.length; i++) {
map.set(removed[i].name, removed[i].color ?? getMemberColor(active.length + i));
}
// "user" = the human operator; gets a unique reserved color
// that is never assigned to any team member.
map.set('user', 'user');
return map;

File diff suppressed because it is too large Load diff