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:
parent
c99a9cfc48
commit
d096b6b19a
9 changed files with 1840 additions and 317 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:`,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -77,24 +77,34 @@ interface MemberColorInput {
|
|||
|
||||
/**
|
||||
* Build a consistent name→colorName 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
Loading…
Reference in a new issue