- {availableTasks.map((t) => {
- const isSelected = related.includes(t.id);
- return (
-
toggleRelated(t.id)}
- >
-
- {isSelected ? '\u2713' : ''}
-
-
- #{t.id}
-
- {t.subject}
-
- );
- })}
+
+ {availableTasks.length > 3 ? (
+
+
+ setRelatedSearch(e.target.value)}
+ className="w-full bg-transparent py-0.5 pl-5 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
+ />
+
+ ) : null}
+
+ {availableTasks
+ .filter(
+ (t) =>
+ !relatedSearch ||
+ t.subject.toLowerCase().includes(relatedSearch.toLowerCase()) ||
+ t.id.includes(relatedSearch)
+ )
+ .map((t) => {
+ const isSelected = related.includes(t.id);
+ return (
+ toggleRelated(t.id)}
+ >
+
+ {isSelected ? '\u2713' : ''}
+
+
+ #{t.id}
+
+ {t.subject}
+
+ );
+ })}
+
{related.length > 0 ? (
diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
index 37d02ab8..2cdaf47f 100644
--- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
+++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
@@ -28,7 +28,7 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { cn } from '@renderer/lib/utils';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { getMemberColor } from '@shared/constants/memberColors';
-import { Check, CheckCircle2, Loader2 } from 'lucide-react';
+import { AlertTriangle, Check, CheckCircle2, Loader2 } from 'lucide-react';
const TEAM_COLOR_NAMES = [
'blue',
@@ -173,6 +173,7 @@ function validateRequest(
options?: { requireCwd?: boolean }
): ValidationResult {
const requireCwd = options?.requireCwd ?? true;
+ // eslint-disable-next-line security/detect-unsafe-regex -- kebab-case pattern is linear, no ReDoS
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(request.teamName) || request.teamName.length > 64) {
return {
valid: false,
@@ -261,6 +262,7 @@ export const CreateTeamDialog = ({
const [isSubmitting, setIsSubmitting] = useState(false);
const [launchTeam, setLaunchTeam] = useState(true);
const [teamColor, setTeamColor] = useState('');
+ const [selectedModel, setSelectedModel] = useState('');
const resetUIState = (): void => {
setLocalError(null);
@@ -281,6 +283,7 @@ export const CreateTeamDialog = ({
setSelectedProjectPath('');
setCustomCwd('');
setLaunchTeam(true);
+ setSelectedModel('');
resetUIState();
};
@@ -460,6 +463,9 @@ export const CreateTeamDialog = ({
[members]
);
+ const effectiveModel =
+ selectedModel && selectedModel !== '__default__' ? selectedModel : undefined;
+
const request = useMemo(
() => ({
teamName: teamName.trim(),
@@ -468,8 +474,9 @@ export const CreateTeamDialog = ({
members: buildMembers(members),
cwd: effectiveCwd,
prompt: prompt.trim() || undefined,
+ model: effectiveModel,
}),
- [teamName, description, teamColor, members, effectiveCwd, prompt]
+ [teamName, description, teamColor, members, effectiveCwd, prompt, effectiveModel]
);
const activeError = localError ?? provisioningError;
@@ -571,7 +578,7 @@ export const CreateTeamDialog = ({
}
}}
>
-
+
{initialData ? 'Copy Team' : 'Create Team'}
@@ -582,17 +589,31 @@ export const CreateTeamDialog = ({
{canCreate && launchTeam && prepareState === 'failed' ? (
-
-
{prepareMessage ?? 'Failed to prepare environment'}
- {prepareWarnings.length > 0 ? (
-
- {prepareWarnings.map((warning) => (
-
- {warning}
-
- ))}
+
+
+
+
+
+ CLI environment is not available — launch is blocked
+
+
+ {prepareMessage ?? 'Failed to prepare environment'}
+
+ {prepareWarnings.length > 0 ? (
+
+ {prepareWarnings.map((warning) => (
+
+ {warning}
+
+ ))}
+
+ ) : null}
+
+ Make sure claude CLI is installed and available
+ in PATH, then reopen this dialog.
+
- ) : null}
+
) : null}
@@ -796,6 +817,23 @@ export const CreateTeamDialog = ({
) : null}
+ {launchTeam ? (
+
+ Model (optional)
+
+
+
+
+
+ Default (account setting)
+ Opus 4.6
+ Sonnet 4.5
+ Haiku 4.5
+
+
+
+ ) : null}
+
{launchTeam ? (
Project
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
index b1f6936d..5f9d4198 100644
--- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
+++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
@@ -14,10 +14,17 @@ import {
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@renderer/components/ui/select';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { cn } from '@renderer/lib/utils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
-import { Check, CheckCircle2, Loader2 } from 'lucide-react';
+import { AlertTriangle, Check, CheckCircle2, Loader2 } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
import type {
@@ -91,6 +98,7 @@ export const LaunchTeamDialog = ({
const [prepareMessage, setPrepareMessage] = useState
(null);
const [prepareWarnings, setPrepareWarnings] = useState([]);
const [isSubmitting, setIsSubmitting] = useState(false);
+ const [selectedModel, setSelectedModel] = useState('');
const resetFormState = (): void => {
setLocalError(null);
@@ -101,6 +109,7 @@ export const LaunchTeamDialog = ({
setCwdMode('project');
setSelectedProjectPath('');
setCustomCwd('');
+ setSelectedModel('');
};
// Warm up CLI on open
@@ -231,6 +240,7 @@ export const LaunchTeamDialog = ({
teamName,
cwd: effectiveCwd,
prompt: promptDraft.value.trim() || undefined,
+ model: selectedModel && selectedModel !== '__default__' ? selectedModel : undefined,
});
resetFormState();
onClose();
@@ -252,7 +262,7 @@ export const LaunchTeamDialog = ({
}
}}
>
-
+
Launch Team
@@ -262,17 +272,31 @@ export const LaunchTeamDialog = ({
{prepareState === 'failed' ? (
-
-
{prepareMessage ?? 'Failed to prepare environment'}
- {prepareWarnings.length > 0 ? (
-
- {prepareWarnings.map((warning) => (
-
- {warning}
-
- ))}
+
+
+
+
+
+ CLI environment is not available — launch is blocked
+
+
+ {prepareMessage ?? 'Failed to prepare environment'}
+
+ {prepareWarnings.length > 0 ? (
+
+ {prepareWarnings.map((warning) => (
+
+ {warning}
+
+ ))}
+
+ ) : null}
+
+ Make sure claude CLI is installed and available
+ in PATH, then reopen this dialog.
+
- ) : null}
+
) : null}
@@ -398,6 +422,21 @@ export const LaunchTeamDialog = ({
}
/>
+
+
+ Model (optional)
+
+
+
+
+
+ Default (account setting)
+ Opus 4.6
+ Sonnet 4.5
+ Haiku 4.5
+
+
+
{activeError ? (
diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx
index 2a9f3720..bf97cfa4 100644
--- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx
+++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx
@@ -118,19 +118,24 @@ export const TaskCommentsSection = ({
: formatDistanceToNow(date, { addSuffix: true });
})()}
-
- setReplyTo({
- author: comment.author,
- text: parseMessageReply(comment.text)?.replyText ?? comment.text,
- })
- }
- >
-
- Reply
-
+
+
+
+ setReplyTo({
+ author: comment.author,
+ text: parseMessageReply(comment.text)?.replyText ?? comment.text,
+ })
+ }
+ >
+
+ Reply
+
+
+ Reply to comment
+
{(() => {
const reply = parseMessageReply(comment.text);
diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx
index a2a0d7b0..7bab3a80 100644
--- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx
+++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
+import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
@@ -13,16 +14,23 @@ import {
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@renderer/components/ui/select';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { markAsRead } from '@renderer/services/commentReadStorage';
+import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
- agentAvatarUrl,
KANBAN_COLUMN_DISPLAY,
TASK_STATUS_LABELS,
TASK_STATUS_STYLES,
} from '@renderer/utils/memberHelpers';
import { formatDistanceToNow } from 'date-fns';
-import { ArrowLeftFromLine, ArrowRightFromLine, Clock, Link2, PenLine, User } from 'lucide-react';
+import { ArrowLeftFromLine, ArrowRightFromLine, Clock, Link2, PenLine } from 'lucide-react';
import { TaskCommentsSection } from './TaskCommentsSection';
@@ -37,6 +45,7 @@ interface TaskDetailDialogProps {
members: ResolvedTeamMember[];
onClose: () => void;
onScrollToTask?: (taskId: string) => void;
+ onOwnerChange?: (taskId: string, owner: string | null) => void;
}
export const TaskDetailDialog = ({
@@ -48,6 +57,7 @@ export const TaskDetailDialog = ({
members,
onClose,
onScrollToTask,
+ onOwnerChange,
}: TaskDetailDialogProps): React.JSX.Element => {
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
@@ -101,10 +111,12 @@ export const TaskDetailDialog = ({
)
.map((t) => t.id);
const ownerMember = currentTask.owner ? members.find((m) => m.name === currentTask.owner) : null;
+ const isTodo = status === 'pending' && !kanbanColumn;
+ const canReassign = isTodo && onOwnerChange;
return (
!v && onClose()}>
-
+
@@ -125,31 +137,46 @@ export const TaskDetailDialog = ({
{/* Metadata */}
- {ownerMember ? (
-
{
+ onOwnerChange(currentTask.id, v === '__unassigned__' ? null : v);
}}
>
-
-
- {ownerMember.name}
-
-
+
+
+
+
+ Unassigned
+ {members.map((m) => {
+ const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
+ const memberColor = m.color ? getTeamColorSet(m.color) : null;
+ return (
+
+
+ {memberColor ? (
+
+ ) : null}
+
+ {m.name}
+
+ {role ? (
+ ({role})
+ ) : null}
+
+
+ );
+ })}
+
+
+ ) : currentTask.owner ? (
+
) : (
-
-
-
- {currentTask.owner ?? '\u2014'}
-
-
+
—
)}
{currentTask.createdBy ? (
diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx
index 714e3e90..8da4c114 100644
--- a/src/renderer/components/team/kanban/KanbanBoard.tsx
+++ b/src/renderer/components/team/kanban/KanbanBoard.tsx
@@ -1,5 +1,9 @@
-import { useMemo, useState } from 'react';
+import { useCallback, useMemo, useState } from 'react';
+import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
+import { arrayMove } from '@dnd-kit/sortable';
+import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils';
@@ -18,6 +22,7 @@ import { KanbanFilterPopover } from './KanbanFilterPopover';
import { KanbanTaskCard } from './KanbanTaskCard';
import type { KanbanFilterState } from './KanbanFilterPopover';
+import type { DragEndEvent } from '@dnd-kit/core';
import type { Session } from '@renderer/types/data';
import type { KanbanColumnId, KanbanState, ResolvedTeamMember, TeamTask } from '@shared/types';
@@ -69,6 +74,10 @@ interface KanbanBoardProps {
onCompleteTask: (taskId: string) => void;
onScrollToTask?: (taskId: string) => void;
onTaskClick?: (task: TeamTask) => void;
+ /** Вызывается после изменения порядка задач в колонке (drag-and-drop). */
+ onColumnOrderChange?: (columnId: KanbanColumnId, orderedTaskIds: string[]) => void;
+ /** Слот слева в одной строке с фильтром и переключателем вида (например, поле поиска). */
+ toolbarLeft?: React.ReactNode;
}
type KanbanViewMode = 'grid' | 'columns';
@@ -99,6 +108,97 @@ function getTaskColumn(task: TeamTask, kanbanState: KanbanState): KanbanColumnId
return null;
}
+/** Сортирует задачи колонки по сохранённому порядку; задачи без порядка — в конце. */
+function sortColumnTasksByOrder(columnTasks: TeamTask[], order?: string[]): TeamTask[] {
+ if (!order?.length) {
+ return columnTasks;
+ }
+ const byId = new Map(columnTasks.map((t) => [t.id, t]));
+ const ordered: TeamTask[] = [];
+ const seen = new Set
();
+ for (const id of order) {
+ const task = byId.get(id);
+ if (task) {
+ ordered.push(task);
+ seen.add(id);
+ }
+ }
+ for (const task of columnTasks) {
+ if (!seen.has(task.id)) {
+ ordered.push(task);
+ }
+ }
+ return ordered;
+}
+
+interface SortableKanbanTaskCardProps {
+ task: TeamTask;
+ columnId: KanbanColumnId;
+ teamName: string;
+ kanbanState: KanbanState;
+ taskMap: Map;
+ members: ResolvedTeamMember[];
+ onRequestReview: (taskId: string) => void;
+ onApprove: (taskId: string) => void;
+ onRequestChanges: (taskId: string) => void;
+ onMoveBackToDone: (taskId: string) => void;
+ onStartTask: (taskId: string) => void;
+ onCompleteTask: (taskId: string) => void;
+ onScrollToTask?: (taskId: string) => void;
+ onTaskClick?: (task: TeamTask) => void;
+}
+
+const SortableKanbanTaskCard = ({
+ task,
+ columnId,
+ teamName,
+ kanbanState,
+ taskMap,
+ members,
+ onRequestReview,
+ onApprove,
+ onRequestChanges,
+ onMoveBackToDone,
+ onStartTask,
+ onCompleteTask,
+ onScrollToTask,
+ onTaskClick,
+}: SortableKanbanTaskCardProps): React.JSX.Element => {
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
+ id: task.id,
+ data: { type: 'kanban-task', columnId, taskId: task.id },
+ });
+
+ const style: React.CSSProperties = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ };
+
+ return (
+ // eslint-disable-next-line react/jsx-props-no-spreading -- dnd-kit useSortable requires spreading attributes/listeners
+
+ 0}
+ taskMap={taskMap}
+ members={members}
+ onRequestReview={onRequestReview}
+ onApprove={onApprove}
+ onRequestChanges={onRequestChanges}
+ onMoveBackToDone={onMoveBackToDone}
+ onStartTask={onStartTask}
+ onCompleteTask={onCompleteTask}
+ onScrollToTask={onScrollToTask}
+ onTaskClick={onTaskClick}
+ />
+
+ );
+};
+
export const KanbanBoard = ({
tasks,
teamName,
@@ -116,6 +216,8 @@ export const KanbanBoard = ({
onCompleteTask,
onScrollToTask,
onTaskClick,
+ onColumnOrderChange,
+ toolbarLeft,
}: KanbanBoardProps): React.JSX.Element => {
const [viewMode, setViewMode] = useState('grid');
@@ -134,6 +236,45 @@ export const KanbanBoard = ({
return result;
}, [tasks, kanbanState]);
+ const groupedOrdered = useMemo(() => {
+ const result = new Map();
+ for (const column of COLUMNS) {
+ const columnTasks = grouped.get(column.id) ?? [];
+ const order = kanbanState.columnOrder?.[column.id];
+ result.set(column.id, sortColumnTasksByOrder(columnTasks, order));
+ }
+ return result;
+ }, [grouped, kanbanState.columnOrder]);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: { distance: 8 },
+ })
+ );
+
+ const handleDragEnd = useCallback(
+ (event: DragEndEvent) => {
+ const { active, over } = event;
+ if (!onColumnOrderChange || !over || active.id === over.id) {
+ return;
+ }
+ const activeData = active.data.current;
+ if (activeData?.type !== 'kanban-task') {
+ return;
+ }
+ const columnId = activeData.columnId as KanbanColumnId;
+ const orderedIds = groupedOrdered.get(columnId)?.map((t) => t.id) ?? [];
+ const oldIndex = orderedIds.indexOf(active.id as string);
+ const newIndex = orderedIds.indexOf(over.id as string);
+ if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) {
+ return;
+ }
+ const newOrder = arrayMove(orderedIds, oldIndex, newIndex);
+ onColumnOrderChange(columnId, newOrder);
+ },
+ [onColumnOrderChange, groupedOrdered]
+ );
+
const renderCards = (columnId: KanbanColumnId, columnTasks: TeamTask[]): React.JSX.Element => {
if (columnTasks.length === 0) {
return (
@@ -142,6 +283,32 @@ export const KanbanBoard = ({
);
}
+ if (onColumnOrderChange) {
+ const itemIds = columnTasks.map((t) => t.id);
+ return (
+
+ {columnTasks.map((task) => (
+
+ ))}
+
+ );
+ }
return (
<>
{columnTasks.map((task) => (
@@ -153,6 +320,7 @@ export const KanbanBoard = ({
kanbanTaskState={kanbanState.tasks[task.id]}
hasReviewers={kanbanState.reviewers.length > 0}
taskMap={taskMap}
+ members={members}
onRequestReview={onRequestReview}
onApprove={onApprove}
onRequestChanges={onRequestChanges}
@@ -167,62 +335,65 @@ export const KanbanBoard = ({
);
};
- return (
-
-
-
-
-
-
- setViewMode('grid')}
- aria-label="Grid view"
- >
-
-
-
- Grid view
-
-
-
- setViewMode('columns')}
- aria-label="Columns view"
- >
-
-
-
- Columns view
-
+ const boardContent = (
+ <>
+
+ {toolbarLeft != null &&
{toolbarLeft}
}
+
+
+
+
+
+ setViewMode('grid')}
+ aria-label="Grid view"
+ >
+
+
+
+ Grid view
+
+
+
+ setViewMode('columns')}
+ aria-label="Columns view"
+ >
+
+
+
+ Columns view
+
+
{viewMode === 'grid' ? (
{COLUMNS.map((column) => {
- const columnTasks = grouped.get(column.id) ?? [];
+ const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[column.id];
return (
{COLUMNS.map((column) => {
- const columnTasks = grouped.get(column.id) ?? [];
+ const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[column.id];
return (
@@ -259,6 +430,16 @@ export const KanbanBoard = ({
})}
)}
-
+ >
);
+
+ if (onColumnOrderChange) {
+ return (
+
+ {boardContent}
+
+ );
+ }
+
+ return boardContent;
};
diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx
index 723f5366..6df814f8 100644
--- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx
+++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx
@@ -1,10 +1,11 @@
+import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
import { ArrowLeftFromLine, ArrowRightFromLine, CheckCircle2, Play } from 'lucide-react';
-import type { KanbanColumnId, KanbanTaskState, TeamTask } from '@shared/types';
+import type { KanbanColumnId, KanbanTaskState, ResolvedTeamMember, TeamTask } from '@shared/types';
interface KanbanTaskCardProps {
task: TeamTask;
@@ -13,6 +14,7 @@ interface KanbanTaskCardProps {
kanbanTaskState?: KanbanTaskState;
hasReviewers: boolean;
taskMap: Map
;
+ members: ResolvedTeamMember[];
onRequestReview: (taskId: string) => void;
onApprove: (taskId: string) => void;
onRequestChanges: (taskId: string) => void;
@@ -63,6 +65,7 @@ export const KanbanTaskCard = ({
kanbanTaskState: _kanbanTaskState,
hasReviewers,
taskMap,
+ members,
onRequestReview,
onApprove,
onRequestChanges,
@@ -96,23 +99,21 @@ export const KanbanTaskCard = ({
}
}}
>
-
-
-
-
- #{task.id}
-
-
-
-
{task.subject}
-
+
+
+ #{task.id}
+
+ {task.owner ? (
+ m.name === task.owner)?.color}
+ />
+ ) : null}
+
+ {task.subject}
+
-
Owner: {task.owner ?? '\u2014'}
-
{hasBlockedBy ? (
@@ -147,112 +148,118 @@ export const KanbanTaskCard = ({
) : null}
- {columnId === 'todo' ? (
-
-
{
- e.stopPropagation();
- onStartTask(task.id);
- }}
- >
-
- Start
-
-
{
- e.stopPropagation();
- onCompleteTask(task.id);
- }}
- >
-
- Complete
-
-
- ) : null}
-
- {columnId === 'in_progress' ? (
-
{
- e.stopPropagation();
- onCompleteTask(task.id);
- }}
- >
-
- Complete
-
- ) : null}
-
- {columnId === 'done' ? (
-
{
- e.stopPropagation();
- onRequestReview(task.id);
- }}
- >
- Request Review
-
- ) : null}
-
- {columnId === 'review' ? (
-
- {!hasReviewers ? (
-
Manual review
+
+
+ {columnId === 'todo' ? (
+ <>
+
{
+ e.stopPropagation();
+ onStartTask(task.id);
+ }}
+ >
+
+ Start
+
+
{
+ e.stopPropagation();
+ onCompleteTask(task.id);
+ }}
+ >
+
+ Complete
+
+ >
) : null}
-
+
+ {columnId === 'in_progress' ? (
{
e.stopPropagation();
- onApprove(task.id);
+ onCompleteTask(task.id);
}}
>
- Approve
+
+ Complete
- {
- e.stopPropagation();
- onRequestChanges(task.id);
- }}
- >
- Request Changes
-
-
-
- ) : null}
+ ) : null}
- {columnId === 'approved' ? (
-
{
- e.stopPropagation();
- onMoveBackToDone(task.id);
- }}
- >
- Move back to DONE
-
- ) : null}
+ {columnId === 'done' ? (
+
{
+ e.stopPropagation();
+ onRequestReview(task.id);
+ }}
+ >
+ Request Review
+
+ ) : null}
+
+ {columnId === 'review' ? (
+
+ {!hasReviewers ? (
+
Manual review
+ ) : null}
+
+ {
+ e.stopPropagation();
+ onApprove(task.id);
+ }}
+ >
+ Approve
+
+ {
+ e.stopPropagation();
+ onRequestChanges(task.id);
+ }}
+ >
+ Request Changes
+
+
+
+ ) : null}
+
+ {columnId === 'approved' ? (
+
{
+ e.stopPropagation();
+ onMoveBackToDone(task.id);
+ }}
+ >
+ Move back to DONE
+
+ ) : null}
+
+
+
+
);
};
diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx
index 48876d9f..88c92bc9 100644
--- a/src/renderer/components/team/members/MemberCard.tsx
+++ b/src/renderer/components/team/members/MemberCard.tsx
@@ -1,8 +1,9 @@
import { Badge } from '@renderer/components/ui/badge';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
-import { ListPlus, Loader2, MessageSquare } from 'lucide-react';
+import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
@@ -15,6 +16,7 @@ interface MemberCardProps {
isTeamProvisioning?: boolean;
currentTask?: TeamTaskWithKanban | null;
isAwaitingReply?: boolean;
+ isRemoved?: boolean;
onOpenTask?: () => void;
onClick?: () => void;
onSendMessage?: () => void;
@@ -29,6 +31,7 @@ export const MemberCard = ({
isTeamProvisioning,
currentTask,
isAwaitingReply,
+ isRemoved,
onOpenTask,
onClick,
onSendMessage,
@@ -41,14 +44,12 @@ export const MemberCard = ({
const inProgress = taskCounts?.inProgress ?? 0;
const completed = taskCounts?.completed ?? 0;
const totalTasks = pending + inProgress + completed;
- const completedRatio = totalTasks > 0 ? completed / totalTasks : 0;
-
- const progressPercent = Math.round(completedRatio * 100);
+ const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
return (
-
+
-
+
{member.name}
+ {member.gitBranch ? (
+
+
+ {member.gitBranch}
+
+ ) : null}
{currentTask ? (
<>
- {presenceLabel}
+ {isRemoved ? 'removed' : presenceLabel}
- 0 ? `${completed}/${totalTasks} completed` : undefined}
>
- {member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'}
-
-
-
{
- e.stopPropagation();
- onSendMessage?.();
- }}
+
-
-
-
{
- e.stopPropagation();
- onAssignTask?.();
- }}
- >
-
-
+ {member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'}
+
+ {totalTasks > 0 && (
+
+ )}
+ {!isRemoved && (
+
+
+
+ {
+ e.stopPropagation();
+ onSendMessage?.();
+ }}
+ >
+
+
+
+ Send message
+
+
+
+ {
+ e.stopPropagation();
+ onAssignTask?.();
+ }}
+ >
+
+
+
+ Assign task
+
+
+ )}
-
);
};
diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx
index 0a52a47a..67adb6e8 100644
--- a/src/renderer/components/team/members/MemberDetailDialog.tsx
+++ b/src/renderer/components/team/members/MemberDetailDialog.tsx
@@ -1,12 +1,12 @@
-import { useMemo } from 'react';
+import { useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
-import { BarChart3, FileText, ListPlus, MessageSquare } from 'lucide-react';
+import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react';
import { MemberDetailHeader } from './MemberDetailHeader';
-import { MemberDetailStats } from './MemberDetailStats';
+import { MemberDetailStats, type MemberDetailTab } from './MemberDetailStats';
import { MemberLogsTab } from './MemberLogsTab';
import { MemberMessagesTab } from './MemberMessagesTab';
import { MemberStatsTab } from './MemberStatsTab';
@@ -26,6 +26,7 @@ interface MemberDetailDialogProps {
onSendMessage: () => void;
onAssignTask: () => void;
onTaskClick: (task: TeamTaskWithKanban) => void;
+ onRemoveMember?: () => void;
}
export const MemberDetailDialog = ({
@@ -40,6 +41,7 @@ export const MemberDetailDialog = ({
onSendMessage,
onAssignTask,
onTaskClick,
+ onRemoveMember,
}: MemberDetailDialogProps): React.JSX.Element | null => {
const memberTasks = useMemo(
() => (member ? tasks.filter((t) => t.owner === member.name) : []),
@@ -61,28 +63,37 @@ export const MemberDetailDialog = ({
[memberTasks]
);
+ const [activeTab, setActiveTab] = useState
('tasks');
+
if (!member) return null;
return (
!nextOpen && onClose()}>
-
-
-
+
+
+
+
+
+
-
+
-
-
-
+ setActiveTab(v as MemberDetailTab)}
+ className="min-w-0 overflow-hidden"
+ >
Tasks
@@ -113,7 +124,7 @@ export const MemberDetailDialog = ({
-
+
@@ -124,14 +135,33 @@ export const MemberDetailDialog = ({
-
-
- Send Message
-
-
-
- Assign Task
-
+ {member.removedAt ? (
+
+ Removed {new Date(member.removedAt).toLocaleDateString()}
+
+ ) : (
+ <>
+
+
+ Send Message
+
+
+
+ Assign Task
+
+ {onRemoveMember && member.agentType !== 'team-lead' && (
+
+
+ Remove
+
+ )}
+ >
+ )}
diff --git a/src/renderer/components/team/members/MemberDetailStats.tsx b/src/renderer/components/team/members/MemberDetailStats.tsx
index a8d0ee1d..5d2e1cf3 100644
--- a/src/renderer/components/team/members/MemberDetailStats.tsx
+++ b/src/renderer/components/team/members/MemberDetailStats.tsx
@@ -1,28 +1,49 @@
import { formatDistanceToNow } from 'date-fns';
+export type MemberDetailTab = 'tasks' | 'messages' | 'stats' | 'logs';
+
interface MemberDetailStatsProps {
totalTasks: number;
inProgressTasks: number;
completedTasks: number;
messageCount: number;
lastActiveAt: string | null;
+ onTabChange?: (tab: MemberDetailTab) => void;
}
+const baseClasses =
+ 'rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1.5';
+const clickableClasses =
+ 'cursor-pointer transition-colors hover:border-[var(--color-border-emphasis)] hover:bg-[var(--color-surface-overlay)]';
+
const StatBlock = ({
label,
value,
sub,
+ onClick,
}: {
label: string;
value: string | number;
sub?: string;
-}): React.JSX.Element => (
-
-
{value}
-
{label}
- {sub &&
{sub}
}
-
-);
+ onClick?: () => void;
+}): React.JSX.Element => {
+ const classes = onClick ? `${baseClasses} ${clickableClasses}` : baseClasses;
+ const content = (
+ <>
+ {value}
+ {label}
+ {sub && {sub}
}
+ >
+ );
+ if (onClick) {
+ return (
+
+ {content}
+
+ );
+ }
+ return {content}
;
+};
export const MemberDetailStats = ({
totalTasks,
@@ -30,21 +51,35 @@ export const MemberDetailStats = ({
completedTasks,
messageCount,
lastActiveAt,
+ onTabChange,
}: MemberDetailStatsProps): React.JSX.Element => {
const lastActive = lastActiveAt
? formatDistanceToNow(new Date(lastActiveAt), { addSuffix: true })
: '—';
return (
-
+
0 ? `in progress: ${inProgressTasks}` : undefined}
+ onClick={onTabChange ? () => onTabChange('tasks') : undefined}
+ />
+ onTabChange('tasks') : undefined}
+ />
+ onTabChange('messages') : undefined}
+ />
+ onTabChange('logs') : undefined}
/>
-
-
-
);
};
diff --git a/src/renderer/components/team/members/MemberExecutionLog.tsx b/src/renderer/components/team/members/MemberExecutionLog.tsx
index b6b14c47..1e639a76 100644
--- a/src/renderer/components/team/members/MemberExecutionLog.tsx
+++ b/src/renderer/components/team/members/MemberExecutionLog.tsx
@@ -4,10 +4,12 @@ import { DisplayItemList } from '@renderer/components/chat/DisplayItemList';
import { LastOutputDisplay } from '@renderer/components/chat/LastOutputDisplay';
import { SystemChatGroup } from '@renderer/components/chat/SystemChatGroup';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
+import { createAgentBlockRegex } from '@shared/constants/agentBlocks';
import { format } from 'date-fns';
-import { Bot, ChevronDown } from 'lucide-react';
+import { Bot, ChevronDown, ChevronRight } from 'lucide-react';
import type { EnhancedChunk } from '@renderer/types/data';
import type { AIGroup, UserGroup } from '@renderer/types/groups';
@@ -88,31 +90,66 @@ export const MemberExecutionLog = ({
);
};
+/** Extract agent-only instruction blocks and human-visible text from a message. */
+function splitAgentBlocks(raw: string): { humanText: string; agentInfo: string[] } {
+ const agentInfo: string[] = [];
+ const regex = createAgentBlockRegex();
+ let m: RegExpExecArray | null;
+ while ((m = regex.exec(raw)) !== null) {
+ const content = m[0]
+ .replace(/^```info_for_agent\n?/, '')
+ .replace(/\n?```$/, '')
+ .trim();
+ if (content) agentInfo.push(content);
+ }
+ const humanText = raw.replace(createAgentBlockRegex(), '').trim();
+ return { humanText, agentInfo };
+}
+
const UserLogItem = ({ group }: { group: UserGroup }): React.JSX.Element => {
const text = group.content.rawText ?? group.content.text ?? '';
- if (!text.trim()) {
+ const { humanText, agentInfo } = useMemo(() => splitAgentBlocks(text), [text]);
+ const [agentInfoOpen, setAgentInfoOpen] = useState(false);
+
+ if (!humanText && agentInfo.length === 0) {
return (
-
-
-
- {format(group.timestamp, 'h:mm:ss a')}
-
-
(empty)
-
+
+ {format(group.timestamp, 'h:mm:ss a')} — (empty)
);
}
return (
-
-
-
- {format(group.timestamp, 'h:mm:ss a')}
-
-
-
-
+
+
+ {format(group.timestamp, 'h:mm:ss a')}
+ {humanText && (
+
+
+
+ )}
+ {agentInfo.length > 0 && (
+
+
setAgentInfoOpen((v) => !v)}
+ >
+
+
+ Agent instructions
+
+ {agentInfoOpen && (
+
+ {agentInfo.join('\n\n')}
+
+ )}
+
+ )}
);
};
@@ -149,23 +186,28 @@ const AIExecutionGroup = ({
return (
{hasToggleContent ? (
-
-
-
- Claude
-
-
- {enhanced.itemsSummary}
-
-
-
+
+
+
+
+
+ Claude
+
+
+ {enhanced.itemsSummary}
+
+
+
+
+ {expanded ? 'Collapse' : 'Expand'}
+
) : null}
{hasToggleContent && expanded ? (
diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx
index 98994ee0..22e5f610 100644
--- a/src/renderer/components/team/members/MemberList.tsx
+++ b/src/renderer/components/team/members/MemberList.tsx
@@ -30,6 +30,9 @@ export const MemberList = ({
onAssignTask,
onOpenTask,
}: MemberListProps): React.JSX.Element => {
+ const activeMembers = members.filter((m) => !m.removedAt);
+ const removedMembers = members.filter((m) => m.removedAt);
+
if (members.length === 0) {
return (
@@ -38,29 +41,46 @@ export const MemberList = ({
);
}
+ const renderCard = (
+ member: ResolvedTeamMember,
+ index: number,
+ isRemoved: boolean
+ ): React.JSX.Element => {
+ const currentTask =
+ member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
+ const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
+ return (
+
onOpenTask?.(currentTask) : undefined}
+ onClick={() => onMemberClick?.(member)}
+ onSendMessage={() => onSendMessage?.(member)}
+ onAssignTask={() => onAssignTask?.(member)}
+ />
+ );
+ };
+
return (
- {members.map((member, index) => {
- const currentTask =
- member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
- const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
- return (
-
onOpenTask?.(currentTask) : undefined}
- onClick={() => onMemberClick?.(member)}
- onSendMessage={() => onSendMessage?.(member)}
- onAssignTask={() => onAssignTask?.(member)}
- />
- );
- })}
+ {activeMembers.map((member, index) => renderCard(member, index, false))}
+ {removedMembers.length > 0 && (
+ <>
+
+ Removed ({removedMembers.length})
+
+ {removedMembers.map((member, index) =>
+ renderCard(member, activeMembers.length + index, true)
+ )}
+ >
+ )}
);
};
diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx
index c6cbf0ee..b553227a 100644
--- a/src/renderer/components/team/members/MemberLogsTab.tsx
+++ b/src/renderer/components/team/members/MemberLogsTab.tsx
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { api } from '@renderer/api';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { formatDuration } from '@renderer/utils/formatters';
import {
AlertCircle,
@@ -207,35 +208,40 @@ const LogCard = ({
return (
-
- {expanded ? (
-
- ) : (
-
- )}
-
-
- {log.description}
-
-
-
-
- {timeAgo}
-
- {log.durationMs > 0 && {formatDuration(log.durationMs)} }
-
-
- {log.messageCount}
-
- {log.isOngoing && (
- active
+
+
+
+ {expanded ? (
+
+ ) : (
+
)}
-
-
-
+
+
+ {log.description}
+
+
+
+
+ {timeAgo}
+
+ {log.durationMs > 0 && {formatDuration(log.durationMs)} }
+
+
+ {log.messageCount}
+
+ {log.isOngoing && (
+ active
+ )}
+
+
+
+
+
{expanded ? 'Hide details' : 'Show details'}
+
{expanded && (
diff --git a/src/renderer/components/team/members/MemberMessagesTab.tsx b/src/renderer/components/team/members/MemberMessagesTab.tsx
index 21ee1ecf..8517c5c9 100644
--- a/src/renderer/components/team/members/MemberMessagesTab.tsx
+++ b/src/renderer/components/team/members/MemberMessagesTab.tsx
@@ -4,6 +4,7 @@ import type { InboxMessage } from '@shared/types';
interface MemberMessagesTabProps {
messages: InboxMessage[];
+ teamName: string;
onCreateTask?: (subject: string, description: string) => void;
}
@@ -11,6 +12,7 @@ const MAX_MESSAGES = 100;
export const MemberMessagesTab = ({
messages,
+ teamName,
onCreateTask,
}: MemberMessagesTabProps): React.JSX.Element => {
const displayMessages = messages.slice(0, MAX_MESSAGES);
@@ -26,7 +28,12 @@ export const MemberMessagesTab = ({
return (
{displayMessages.map((msg, idx) => (
-
+
))}
);
diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx
new file mode 100644
index 00000000..ed43a856
--- /dev/null
+++ b/src/renderer/components/team/messages/MessageComposer.tsx
@@ -0,0 +1,328 @@
+import { useCallback, useMemo, useRef, useState } from 'react';
+
+import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList';
+import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay';
+import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
+import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
+import { getTeamColorSet } from '@renderer/constants/teamColors';
+import { useAttachments } from '@renderer/hooks/useAttachments';
+import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
+import { cn } from '@renderer/lib/utils';
+import { formatAgentRole } from '@renderer/utils/formatAgentRole';
+import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
+import { AlertCircle, Check, ChevronDown, Paperclip, Send } from 'lucide-react';
+
+import type { MentionSuggestion } from '@renderer/types/mention';
+import type { AttachmentPayload, ResolvedTeamMember } from '@shared/types';
+
+interface MessageComposerProps {
+ teamName: string;
+ members: ResolvedTeamMember[];
+ isTeamAlive?: boolean;
+ sending: boolean;
+ sendError: string | null;
+ onSend: (
+ recipient: string,
+ text: string,
+ summary?: string,
+ attachments?: AttachmentPayload[]
+ ) => void;
+}
+
+const MAX_MESSAGE_LENGTH = 4000;
+
+export const MessageComposer = ({
+ teamName,
+ members,
+ isTeamAlive,
+ sending,
+ sendError,
+ onSend,
+}: MessageComposerProps): React.JSX.Element => {
+ const [recipient, setRecipient] = useState
(() => {
+ const lead = members.find((m) => m.role === 'lead' || m.name === 'team-lead');
+ return lead?.name ?? members[0]?.name ?? '';
+ });
+ const [recipientOpen, setRecipientOpen] = useState(false);
+ const [isDragOver, setIsDragOver] = useState(false);
+ const dragCounterRef = useRef(0);
+ const fileInputRef = useRef(null);
+
+ const draft = useDraftPersistence({ key: `compose:${teamName}` });
+ const {
+ attachments,
+ error: attachmentError,
+ canAddMore,
+ addFiles,
+ removeAttachment,
+ clearAttachments,
+ handlePaste,
+ handleDrop,
+ } = useAttachments();
+
+ const mentionSuggestions = useMemo(
+ () =>
+ members.map((m) => ({
+ id: m.name,
+ name: m.name,
+ subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
+ color: m.color,
+ })),
+ [members]
+ );
+
+ const trimmed = draft.value.trim();
+ const canSend =
+ recipient.length > 0 && trimmed.length > 0 && trimmed.length <= MAX_MESSAGE_LENGTH && !sending;
+
+ const selectedMember = members.find((m) => m.name === recipient);
+ const selectedColorSet = selectedMember?.color ? getTeamColorSet(selectedMember.color) : null;
+ const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
+ const canAttach = isLeadRecipient && isTeamAlive && canAddMore;
+
+ const handleSend = useCallback(() => {
+ if (!canSend) return;
+ const autoSummary = trimmed.length > 60 ? trimmed.slice(0, 57) + '...' : trimmed;
+ onSend(recipient, trimmed, autoSummary, attachments.length > 0 ? attachments : undefined);
+ draft.clearDraft();
+ clearAttachments();
+ }, [canSend, recipient, trimmed, onSend, draft, attachments, clearAttachments]);
+
+ const handleKeyDownCapture = useCallback(
+ (e: React.KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
+ e.preventDefault();
+ e.stopPropagation();
+ handleSend();
+ }
+ },
+ [handleSend]
+ );
+
+ const handleFileInputChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const input = e.target;
+ if (input.files?.length) {
+ void addFiles(input.files);
+ }
+ input.value = '';
+ },
+ [addFiles]
+ );
+
+ const handleDragEnter = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ dragCounterRef.current += 1;
+ if (dragCounterRef.current === 1) setIsDragOver(true);
+ }, []);
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ dragCounterRef.current -= 1;
+ if (dragCounterRef.current <= 0) {
+ dragCounterRef.current = 0;
+ setIsDragOver(false);
+ }
+ }, []);
+
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ }, []);
+
+ const handleDropWrapper = useCallback(
+ (e: React.DragEvent) => {
+ dragCounterRef.current = 0;
+ setIsDragOver(false);
+ if (canAttach) handleDrop(e);
+ },
+ [canAttach, handleDrop]
+ );
+
+ const handlePasteWrapper = useCallback(
+ (e: React.ClipboardEvent) => {
+ if (canAttach) handlePaste(e);
+ },
+ [canAttach, handlePaste]
+ );
+
+ const remaining = MAX_MESSAGE_LENGTH - trimmed.length;
+
+ return (
+
+
+
+
+
+
+
+ {selectedColorSet ? (
+
+ ) : (
+
+ )}
+
+ {recipient || 'Select...'}
+
+
+
+
+
+
+ {members.map((m) => {
+ const colorSet = m.color ? getTeamColorSet(m.color) : null;
+ const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
+ const isSelected = m.name === recipient;
+ return (
+ {
+ setRecipient(m.name);
+ setRecipientOpen(false);
+ }}
+ >
+ {colorSet ? (
+
+ ) : (
+
+ )}
+
+ {m.name}
+
+ {role ? (
+
+ {role}
+
+ ) : null}
+ {isSelected ? (
+
+ ) : null}
+
+ );
+ })}
+
+
+
+
+ {isLeadRecipient ? (
+ <>
+
+
+
+ fileInputRef.current?.click()}
+ >
+
+
+
+
+ {!isTeamAlive
+ ? 'Team must be online to attach images'
+ : !canAddMore
+ ? 'Maximum attachments reached'
+ : 'Attach images (paste or drag & drop)'}
+
+
+ >
+ ) : null}
+
+ {!isTeamAlive ? (
+
Team offline
+ ) : null}
+
+
+
+
+
+
+ Send
+
+ }
+ footerRight={
+
+ {sendError ? (
+
+
+ {sendError}
+
+ ) : null}
+ {remaining < 200 ? (
+
+ {remaining} chars left
+
+ ) : null}
+ {draft.isSaved ? (
+
Draft saved
+ ) : null}
+
+ }
+ />
+
+ );
+};
diff --git a/src/renderer/components/team/messages/MessagesFilterPopover.tsx b/src/renderer/components/team/messages/MessagesFilterPopover.tsx
index eb26d984..547193ca 100644
--- a/src/renderer/components/team/messages/MessagesFilterPopover.tsx
+++ b/src/renderer/components/team/messages/MessagesFilterPopover.tsx
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { Filter } from 'lucide-react';
import type { InboxMessage } from '@shared/types';
@@ -93,22 +94,26 @@ export const MessagesFilterPopover = ({
return (
-
-
-
- {activeCount > 0 && (
-
- {activeCount}
-
- )}
-
-
+
+
+
+
+
+ {activeCount > 0 && (
+
+ {activeCount}
+
+ )}
+
+
+
+ Filter messages
+
diff --git a/src/renderer/components/ui/dialog.tsx b/src/renderer/components/ui/dialog.tsx
index df8db14a..47af98e8 100644
--- a/src/renderer/components/ui/dialog.tsx
+++ b/src/renderer/components/ui/dialog.tsx
@@ -35,6 +35,7 @@ const DialogContent = React.forwardRef<
ref={ref}
className={cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
+ 'max-h-[90vh] min-h-0 overflow-y-auto overflow-x-hidden',
className
)}
{...props}
diff --git a/src/renderer/components/ui/popover.tsx b/src/renderer/components/ui/popover.tsx
index eec198e3..ab5c334d 100644
--- a/src/renderer/components/ui/popover.tsx
+++ b/src/renderer/components/ui/popover.tsx
@@ -18,7 +18,7 @@ const PopoverContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
className={cn(
- 'z-50 w-72 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-4 text-[var(--color-text)] shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
+ 'z-50 max-h-[min(80vh,24rem)] min-h-0 w-72 overflow-y-auto overflow-x-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-4 text-[var(--color-text)] shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
diff --git a/src/renderer/hooks/useAttachments.ts b/src/renderer/hooks/useAttachments.ts
new file mode 100644
index 00000000..001f2e8f
--- /dev/null
+++ b/src/renderer/hooks/useAttachments.ts
@@ -0,0 +1,135 @@
+import { useCallback, useState } from 'react';
+
+import {
+ fileToAttachmentPayload,
+ MAX_FILES,
+ MAX_TOTAL_SIZE,
+ validateAttachment,
+} from '@renderer/utils/attachmentUtils';
+
+import type { AttachmentPayload } from '@shared/types';
+
+interface UseAttachmentsReturn {
+ attachments: AttachmentPayload[];
+ error: string | null;
+ totalSize: number;
+ canAddMore: boolean;
+ addFiles: (files: FileList | File[]) => Promise;
+ removeAttachment: (id: string) => void;
+ clearAttachments: () => void;
+ handlePaste: (event: React.ClipboardEvent) => void;
+ handleDrop: (event: React.DragEvent) => void;
+}
+
+export function useAttachments(): UseAttachmentsReturn {
+ const [attachments, setAttachments] = useState([]);
+ const [error, setError] = useState(null);
+
+ const totalSize = attachments.reduce((sum, a) => sum + a.size, 0);
+ const canAddMore = attachments.length < MAX_FILES && totalSize < MAX_TOTAL_SIZE;
+
+ const addFiles = useCallback(async (files: FileList | File[]) => {
+ setError(null);
+ const fileArray = Array.from(files);
+ if (fileArray.length === 0) return;
+
+ let batchSize = 0;
+ let valid = true;
+ for (const file of fileArray) {
+ const validation = validateAttachment(file);
+ if (!validation.valid) {
+ setError(validation.error);
+ valid = false;
+ break;
+ }
+ batchSize += file.size;
+ }
+ if (!valid) return;
+
+ const newPayloads: AttachmentPayload[] = [];
+ for (const file of fileArray) {
+ try {
+ const payload = await fileToAttachmentPayload(file);
+ newPayloads.push(payload);
+ } catch {
+ setError(`Failed to read file: ${file.name}`);
+ valid = false;
+ break;
+ }
+ }
+ if (!valid) return;
+
+ setAttachments((prev) => {
+ if (prev.length + newPayloads.length > MAX_FILES) {
+ setError(`Maximum ${MAX_FILES} attachments allowed`);
+ return prev;
+ }
+ const currentTotal = prev.reduce((sum, a) => sum + a.size, 0);
+ if (currentTotal + batchSize > MAX_TOTAL_SIZE) {
+ setError('Total attachment size exceeds 20MB limit');
+ return prev;
+ }
+ return [...prev, ...newPayloads];
+ });
+ }, []);
+
+ const removeAttachment = useCallback((id: string) => {
+ setAttachments((prev) => prev.filter((a) => a.id !== id));
+ setError(null);
+ }, []);
+
+ const clearAttachments = useCallback(() => {
+ setAttachments([]);
+ setError(null);
+ }, []);
+
+ const handlePaste = useCallback(
+ (event: React.ClipboardEvent) => {
+ const items = event.clipboardData?.items;
+ if (!items) return;
+
+ const imageFiles: File[] = [];
+ for (const item of Array.from(items)) {
+ if (item.kind === 'file' && item.type.startsWith('image/')) {
+ const file = item.getAsFile();
+ if (file) imageFiles.push(file);
+ }
+ }
+
+ if (imageFiles.length > 0) {
+ event.preventDefault();
+ void addFiles(imageFiles);
+ }
+ },
+ [addFiles]
+ );
+
+ const handleDrop = useCallback(
+ (event: React.DragEvent) => {
+ event.preventDefault();
+ const files = event.dataTransfer?.files;
+ if (!files?.length) return;
+
+ const allFiles = Array.from(files);
+ const imageFiles = allFiles.filter((f) => f.type.startsWith('image/'));
+ if (imageFiles.length > 0) {
+ void addFiles(imageFiles);
+ } else if (allFiles.length > 0) {
+ setError('Only image files are supported');
+ }
+ },
+ [addFiles]
+ );
+
+ return {
+ attachments,
+ error,
+ totalSize,
+ canAddMore,
+ addFiles,
+ removeAttachment,
+ clearAttachments,
+ handlePaste,
+ handleDrop,
+ };
+}
diff --git a/src/renderer/store/slices/notificationSlice.ts b/src/renderer/store/slices/notificationSlice.ts
index 320c71a7..d9fc16df 100644
--- a/src/renderer/store/slices/notificationSlice.ts
+++ b/src/renderer/store/slices/notificationSlice.ts
@@ -206,6 +206,13 @@ export const createNotificationSlice: StateCreator = (set, ge
(wt) => normalizePath(wt.path) === normalizedTeamPath
);
if (matchingWorktree && state.selectedWorktreeId !== matchingWorktree.id) {
- state.selectRepository(repo.id);
- state.selectWorktree(matchingWorktree.id);
+ set(getWorktreeNavigationState(repo.id, matchingWorktree.id));
+ void get().fetchSessionsInitial(matchingWorktree.id);
break;
}
}
diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts
index 547d5abb..0e32fcba 100644
--- a/src/renderer/store/slices/teamSlice.ts
+++ b/src/renderer/store/slices/teamSlice.ts
@@ -3,12 +3,16 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
import { createLogger } from '@shared/utils/logger';
+import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
+
const logger = createLogger('teamSlice');
import type { AppState } from '../types';
import type {
+ AddMemberRequest,
CreateTaskRequest,
GlobalTask,
+ KanbanColumnId,
SendMessageRequest,
SendMessageResult,
TaskComment,
@@ -71,12 +75,20 @@ export interface TeamSlice {
sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise;
requestReview: (teamName: string, taskId: string) => Promise;
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise;
+ updateKanbanColumnOrder: (
+ teamName: string,
+ columnId: KanbanColumnId,
+ orderedTaskIds: string[]
+ ) => Promise;
createTeamTask: (teamName: string, request: CreateTaskRequest) => Promise;
startTask: (teamName: string, taskId: string) => Promise;
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise;
+ updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise;
addingComment: boolean;
addCommentError: string | null;
addTaskComment: (teamName: string, taskId: string, text: string) => Promise;
+ addMember: (teamName: string, request: AddMemberRequest) => Promise;
+ removeMember: (teamName: string, memberName: string) => Promise;
deleteTeam: (teamName: string) => Promise;
createTeam: (request: TeamCreateRequest) => Promise;
launchTeam: (request: TeamLaunchRequest) => Promise;
@@ -180,14 +192,22 @@ export const createTeamSlice: StateCreator = (set,
}
const state = get();
+ // Use display name from teams list or selected team data if available
+ const teamSummary = state.teams.find((t) => t.teamName === teamName);
+ const displayName = teamSummary?.displayName || state.selectedTeamData?.config.name || teamName;
+
const allTabs = state.getAllPaneTabs();
const existing = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName);
if (existing) {
state.setActiveTab(existing.id);
+ // Sync label in case display name changed
+ if (existing.label !== displayName) {
+ state.updateTabLabel(existing.id, displayName);
+ }
} else {
state.openTab({
type: 'team',
- label: teamName,
+ label: displayName,
teamName,
});
}
@@ -222,6 +242,14 @@ export const createTeamSlice: StateCreator = (set,
selectedTeamError: null,
});
+ // Sync tab label with the team's display name from config
+ const displayName = data.config.name || teamName;
+ const allTabs = get().getAllPaneTabs();
+ const teamTab = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName);
+ if (teamTab && teamTab.label !== displayName) {
+ get().updateTabLabel(teamTab.id, displayName);
+ }
+
// Auto-select the project associated with this team's cwd/projectPath.
// Must search both flat projects and grouped repositoryGroups/worktrees
// because the default viewMode is 'grouped' and flat projects may be empty.
@@ -244,8 +272,8 @@ export const createTeamSlice: StateCreator = (set,
);
if (matchingWorktree) {
if (state.selectedWorktreeId !== matchingWorktree.id) {
- state.selectRepository(repo.id);
- state.selectWorktree(matchingWorktree.id);
+ set(getWorktreeNavigationState(repo.id, matchingWorktree.id));
+ void get().fetchSessionsInitial(matchingWorktree.id);
}
break;
}
@@ -330,6 +358,17 @@ export const createTeamSlice: StateCreator = (set,
}
},
+ updateKanbanColumnOrder: async (
+ teamName: string,
+ columnId: KanbanColumnId,
+ orderedTaskIds: string[]
+ ) => {
+ await unwrapIpc('team:updateKanbanColumnOrder', () =>
+ api.teams.updateKanbanColumnOrder(teamName, columnId, orderedTaskIds)
+ );
+ await get().refreshTeamData(teamName);
+ },
+
sendTeamMessage: async (teamName: string, request: SendMessageRequest) => {
set({ sendingMessage: true, sendMessageError: null, lastSendMessageResult: null });
try {
@@ -382,6 +421,13 @@ export const createTeamSlice: StateCreator = (set,
await get().refreshTeamData(teamName);
},
+ updateTaskOwner: async (teamName: string, taskId: string, owner: string | null) => {
+ await unwrapIpc('team:updateTaskOwner', () =>
+ api.teams.updateTaskOwner(teamName, taskId, owner)
+ );
+ await get().refreshTeamData(teamName);
+ },
+
addTaskComment: async (teamName, taskId, text) => {
set({ addingComment: true, addCommentError: null });
try {
@@ -398,6 +444,16 @@ export const createTeamSlice: StateCreator = (set,
}
},
+ addMember: async (teamName: string, request: AddMemberRequest) => {
+ await unwrapIpc('team:addMember', () => api.teams.addMember(teamName, request));
+ await get().refreshTeamData(teamName);
+ },
+
+ removeMember: async (teamName: string, memberName: string) => {
+ await unwrapIpc('team:removeMember', () => api.teams.removeMember(teamName, memberName));
+ await get().refreshTeamData(teamName);
+ },
+
deleteTeam: async (teamName: string) => {
await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName));
const state = get();
diff --git a/src/renderer/store/utils/stateResetHelpers.ts b/src/renderer/store/utils/stateResetHelpers.ts
index 7fdc4a0b..8656ab3a 100644
--- a/src/renderer/store/utils/stateResetHelpers.ts
+++ b/src/renderer/store/utils/stateResetHelpers.ts
@@ -24,6 +24,22 @@ export function getSessionResetState(): Partial {
};
}
+/**
+ * Atomically navigate to a specific worktree.
+ * Use instead of selectRepository() + selectWorktree() to avoid race condition
+ * (two competing fetchSessionsInitial calls where the stale response can overwrite).
+ */
+export function getWorktreeNavigationState(repoId: string, worktreeId: string): Partial {
+ return {
+ selectedRepositoryId: repoId,
+ selectedWorktreeId: worktreeId,
+ selectedProjectId: worktreeId,
+ activeProjectId: worktreeId,
+ sidebarCollapsed: false,
+ ...getSessionResetState(),
+ };
+}
+
/**
* Full state reset (session + project + repository + conversation).
* Used when closing all tabs or resetting to initial state.
diff --git a/src/renderer/utils/attachmentUtils.ts b/src/renderer/utils/attachmentUtils.ts
new file mode 100644
index 00000000..c88e5f46
--- /dev/null
+++ b/src/renderer/utils/attachmentUtils.ts
@@ -0,0 +1,52 @@
+import type { AttachmentMediaType, AttachmentPayload } from '@shared/types';
+
+export const ALLOWED_MIME_TYPES = new Set([
+ 'image/png',
+ 'image/jpeg',
+ 'image/gif',
+ 'image/webp',
+]);
+
+export const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
+export const MAX_FILES = 5;
+export const MAX_TOTAL_SIZE = 20 * 1024 * 1024; // 20MB
+
+export function isImageMimeType(type: string): type is AttachmentMediaType {
+ return ALLOWED_MIME_TYPES.has(type as AttachmentMediaType);
+}
+
+export function validateAttachment(file: File): { valid: true } | { valid: false; error: string } {
+ if (!isImageMimeType(file.type)) {
+ return { valid: false, error: `Unsupported file type: ${file.type}` };
+ }
+ if (file.size > MAX_FILE_SIZE) {
+ return { valid: false, error: `File "${file.name}" exceeds 10MB limit` };
+ }
+ return { valid: true };
+}
+
+export async function fileToAttachmentPayload(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ const dataUrl = reader.result as string;
+ // Strip "data:image/png;base64," prefix to get raw base64
+ const base64 = dataUrl.split(',')[1] ?? '';
+ resolve({
+ id: crypto.randomUUID(),
+ filename: file.name,
+ mimeType: file.type as AttachmentMediaType,
+ size: file.size,
+ data: base64,
+ });
+ };
+ reader.onerror = () => reject(new Error(`Failed to read file: ${file.name}`));
+ reader.readAsDataURL(file);
+ });
+}
+
+export function formatFileSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
diff --git a/src/renderer/utils/pathNormalize.ts b/src/renderer/utils/pathNormalize.ts
index 8c62a4c3..c8bb0e8b 100644
--- a/src/renderer/utils/pathNormalize.ts
+++ b/src/renderer/utils/pathNormalize.ts
@@ -1,7 +1,9 @@
import type { GlobalTask } from '@shared/types';
export function normalizePath(p: string): string {
- return p.endsWith('/') ? p.slice(0, -1) : p;
+ let s = p.replace(/\\/g, '/');
+ while (s.endsWith('/')) s = s.slice(0, -1);
+ return s.toLowerCase();
}
export interface TaskStatusCounts {
diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts
index 08c5b5d7..56873df9 100644
--- a/src/shared/types/api.ts
+++ b/src/shared/types/api.ts
@@ -14,8 +14,11 @@ import type {
TriggerTestResult,
} from './notifications';
import type {
+ AddMemberRequest,
+ AttachmentFileData,
CreateTaskRequest,
GlobalTask,
+ KanbanColumnId,
MemberFullStats,
MemberLogSummary,
SendMessageRequest,
@@ -354,7 +357,13 @@ export interface TeamsAPI {
createTask: (teamName: string, request: CreateTaskRequest) => Promise;
requestReview: (teamName: string, taskId: string) => Promise;
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise;
+ updateKanbanColumnOrder: (
+ teamName: string,
+ columnId: KanbanColumnId,
+ orderedTaskIds: string[]
+ ) => Promise;
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise;
+ updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise;
startTask: (teamName: string, taskId: string) => Promise;
processSend: (teamName: string, message: string) => Promise;
processAlive: (teamName: string) => Promise;
@@ -371,7 +380,11 @@ export interface TeamsAPI {
launchTeam: (request: TeamLaunchRequest) => Promise;
getAllTasks: () => Promise;
updateConfig: (teamName: string, updates: TeamUpdateConfigRequest) => Promise;
+ addMember: (teamName: string, request: AddMemberRequest) => Promise;
+ removeMember: (teamName: string, memberName: string) => Promise;
addTaskComment: (teamName: string, taskId: string, text: string) => Promise;
+ getProjectBranch: (projectPath: string) => Promise;
+ getAttachments: (teamName: string, messageId: string) => Promise;
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;
onProvisioningProgress: (
callback: (event: unknown, data: TeamProvisioningProgress) => void
diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts
index 6e51f757..b7d757a2 100644
--- a/src/shared/types/notifications.ts
+++ b/src/shared/types/notifications.ts
@@ -262,6 +262,8 @@ export interface AppConfig {
defaultTab: 'dashboard' | 'last-session';
/** Optional custom Claude root folder (auto-detected when null) */
claudeRootPath: string | null;
+ /** Agent communication language ('system' = use OS locale) */
+ agentLanguage: string;
};
/** Display and UI settings */
display: {
diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts
index 015a09bc..c2932329 100644
--- a/src/shared/types/team.ts
+++ b/src/shared/types/team.ts
@@ -5,6 +5,8 @@ export interface TeamMember {
role?: string;
color?: string;
joinedAt?: number;
+ cwd?: string;
+ removedAt?: number;
}
export interface TeamConfig {
@@ -80,6 +82,25 @@ export interface TeamTaskWithKanban extends TeamTask {
kanbanColumn?: 'review' | 'approved';
}
+export type AttachmentMediaType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
+
+export interface AttachmentMeta {
+ id: string;
+ filename: string;
+ mimeType: AttachmentMediaType;
+ size: number;
+}
+
+export interface AttachmentPayload extends AttachmentMeta {
+ data: string;
+}
+
+export interface AttachmentFileData {
+ id: string;
+ data: string;
+ mimeType: AttachmentMediaType;
+}
+
export interface InboxMessage {
from: string;
to?: string;
@@ -89,7 +110,8 @@ export interface InboxMessage {
summary?: string;
color?: string;
messageId?: string;
- source?: 'inbox' | 'lead_session' | 'lead_process';
+ source?: 'inbox' | 'lead_session' | 'lead_process' | 'user_sent';
+ attachments?: AttachmentMeta[];
}
export interface SendMessageRequest {
@@ -97,10 +119,12 @@ export interface SendMessageRequest {
text: string;
summary?: string;
from?: string;
+ attachments?: AttachmentPayload[];
}
export interface SendMessageResult {
deliveredToInbox: boolean;
+ deliveredViaStdin?: boolean;
messageId: string;
}
@@ -119,6 +143,8 @@ export interface KanbanState {
teamName: string;
reviewers: string[];
tasks: Record;
+ /** Порядок id задач по колонкам для отображения на канбан-доске (drag-and-drop). */
+ columnOrder?: Partial>;
}
export type UpdateKanbanPatch =
@@ -136,6 +162,10 @@ export interface ResolvedTeamMember {
color?: string;
agentType?: string;
role?: string;
+ cwd?: string;
+ /** Set only when member's git branch differs from the lead's branch. */
+ gitBranch?: string;
+ removedAt?: number;
}
export interface TeamData {
@@ -153,6 +183,7 @@ export interface TeamLaunchRequest {
teamName: string;
cwd: string;
prompt?: string;
+ model?: string;
}
export interface TeamLaunchResponse {
@@ -199,6 +230,7 @@ export interface TeamCreateRequest {
members: TeamProvisioningMemberInput[];
cwd: string;
prompt?: string;
+ model?: string;
}
export interface TeamCreateConfigRequest {
@@ -290,3 +322,12 @@ export interface MemberFullStats {
sessionCount: number;
computedAt: string;
}
+
+export interface AddMemberRequest {
+ name: string;
+ role?: string;
+}
+
+export interface RemoveMemberRequest {
+ name: string;
+}
diff --git a/src/shared/utils/agentLanguage.ts b/src/shared/utils/agentLanguage.ts
new file mode 100644
index 00000000..c3ad35c0
--- /dev/null
+++ b/src/shared/utils/agentLanguage.ts
@@ -0,0 +1,122 @@
+/**
+ * Agent language configuration utilities.
+ * Pure functions — no Electron or DOM dependencies.
+ */
+
+export interface AgentLanguageOption {
+ readonly value: string;
+ readonly label: string;
+ readonly flag: string;
+}
+
+/** Curated list of language options for agent communication (sorted alphabetically after System). */
+export const AGENT_LANGUAGE_OPTIONS: readonly AgentLanguageOption[] = [
+ { value: 'system', label: 'System', flag: '\u{1F310}' },
+ { value: 'af', label: 'Afrikaans', flag: '\u{1F1FF}\u{1F1E6}' },
+ { value: 'am', label: 'Amharic', flag: '\u{1F1EA}\u{1F1F9}' },
+ { value: 'ar', label: 'Arabic', flag: '\u{1F1F8}\u{1F1E6}' },
+ { value: 'az', label: 'Azerbaijani', flag: '\u{1F1E6}\u{1F1FF}' },
+ { value: 'be', label: 'Belarusian', flag: '\u{1F1E7}\u{1F1FE}' },
+ { value: 'bg', label: 'Bulgarian', flag: '\u{1F1E7}\u{1F1EC}' },
+ { value: 'bn', label: 'Bengali', flag: '\u{1F1E7}\u{1F1E9}' },
+ { value: 'bs', label: 'Bosnian', flag: '\u{1F1E7}\u{1F1E6}' },
+ { value: 'ca', label: 'Catalan', flag: '\u{1F1EA}\u{1F1F8}' },
+ { value: 'cs', label: 'Czech', flag: '\u{1F1E8}\u{1F1FF}' },
+ {
+ value: 'cy',
+ label: 'Welsh',
+ flag: '\u{1F3F4}\u{E0067}\u{E0062}\u{E0077}\u{E006C}\u{E0073}\u{E007F}',
+ },
+ { value: 'da', label: 'Danish', flag: '\u{1F1E9}\u{1F1F0}' },
+ { value: 'de', label: 'German', flag: '\u{1F1E9}\u{1F1EA}' },
+ { value: 'el', label: 'Greek', flag: '\u{1F1EC}\u{1F1F7}' },
+ { value: 'en', label: 'English', flag: '\u{1F1EC}\u{1F1E7}' },
+ { value: 'es', label: 'Spanish', flag: '\u{1F1EA}\u{1F1F8}' },
+ { value: 'et', label: 'Estonian', flag: '\u{1F1EA}\u{1F1EA}' },
+ { value: 'eu', label: 'Basque', flag: '\u{1F1EA}\u{1F1F8}' },
+ { value: 'fa', label: 'Persian', flag: '\u{1F1EE}\u{1F1F7}' },
+ { value: 'fi', label: 'Finnish', flag: '\u{1F1EB}\u{1F1EE}' },
+ { value: 'fil', label: 'Filipino', flag: '\u{1F1F5}\u{1F1ED}' },
+ { value: 'fr', label: 'French', flag: '\u{1F1EB}\u{1F1F7}' },
+ { value: 'ga', label: 'Irish', flag: '\u{1F1EE}\u{1F1EA}' },
+ { value: 'gl', label: 'Galician', flag: '\u{1F1EA}\u{1F1F8}' },
+ { value: 'gu', label: 'Gujarati', flag: '\u{1F1EE}\u{1F1F3}' },
+ { value: 'he', label: 'Hebrew', flag: '\u{1F1EE}\u{1F1F1}' },
+ { value: 'hi', label: 'Hindi', flag: '\u{1F1EE}\u{1F1F3}' },
+ { value: 'hr', label: 'Croatian', flag: '\u{1F1ED}\u{1F1F7}' },
+ { value: 'hu', label: 'Hungarian', flag: '\u{1F1ED}\u{1F1FA}' },
+ { value: 'hy', label: 'Armenian', flag: '\u{1F1E6}\u{1F1F2}' },
+ { value: 'id', label: 'Indonesian', flag: '\u{1F1EE}\u{1F1E9}' },
+ { value: 'is', label: 'Icelandic', flag: '\u{1F1EE}\u{1F1F8}' },
+ { value: 'it', label: 'Italian', flag: '\u{1F1EE}\u{1F1F9}' },
+ { value: 'ja', label: 'Japanese', flag: '\u{1F1EF}\u{1F1F5}' },
+ { value: 'ka', label: 'Georgian', flag: '\u{1F1EC}\u{1F1EA}' },
+ { value: 'kk', label: 'Kazakh', flag: '\u{1F1F0}\u{1F1FF}' },
+ { value: 'km', label: 'Khmer', flag: '\u{1F1F0}\u{1F1ED}' },
+ { value: 'kn', label: 'Kannada', flag: '\u{1F1EE}\u{1F1F3}' },
+ { value: 'ko', label: 'Korean', flag: '\u{1F1F0}\u{1F1F7}' },
+ { value: 'lt', label: 'Lithuanian', flag: '\u{1F1F1}\u{1F1F9}' },
+ { value: 'lv', label: 'Latvian', flag: '\u{1F1F1}\u{1F1FB}' },
+ { value: 'mk', label: 'Macedonian', flag: '\u{1F1F2}\u{1F1F0}' },
+ { value: 'ml', label: 'Malayalam', flag: '\u{1F1EE}\u{1F1F3}' },
+ { value: 'mn', label: 'Mongolian', flag: '\u{1F1F2}\u{1F1F3}' },
+ { value: 'mr', label: 'Marathi', flag: '\u{1F1EE}\u{1F1F3}' },
+ { value: 'ms', label: 'Malay', flag: '\u{1F1F2}\u{1F1FE}' },
+ { value: 'my', label: 'Burmese', flag: '\u{1F1F2}\u{1F1F2}' },
+ { value: 'ne', label: 'Nepali', flag: '\u{1F1F3}\u{1F1F5}' },
+ { value: 'nl', label: 'Dutch', flag: '\u{1F1F3}\u{1F1F1}' },
+ { value: 'no', label: 'Norwegian', flag: '\u{1F1F3}\u{1F1F4}' },
+ { value: 'pa', label: 'Punjabi', flag: '\u{1F1EE}\u{1F1F3}' },
+ { value: 'pl', label: 'Polish', flag: '\u{1F1F5}\u{1F1F1}' },
+ { value: 'pt', label: 'Portuguese', flag: '\u{1F1E7}\u{1F1F7}' },
+ { value: 'ro', label: 'Romanian', flag: '\u{1F1F7}\u{1F1F4}' },
+ { value: 'ru', label: 'Russian', flag: '\u{1F1F7}\u{1F1FA}' },
+ { value: 'si', label: 'Sinhala', flag: '\u{1F1F1}\u{1F1F0}' },
+ { value: 'sk', label: 'Slovak', flag: '\u{1F1F8}\u{1F1F0}' },
+ { value: 'sl', label: 'Slovenian', flag: '\u{1F1F8}\u{1F1EE}' },
+ { value: 'sq', label: 'Albanian', flag: '\u{1F1E6}\u{1F1F1}' },
+ { value: 'sr', label: 'Serbian', flag: '\u{1F1F7}\u{1F1F8}' },
+ { value: 'sv', label: 'Swedish', flag: '\u{1F1F8}\u{1F1EA}' },
+ { value: 'sw', label: 'Swahili', flag: '\u{1F1F0}\u{1F1EA}' },
+ { value: 'ta', label: 'Tamil', flag: '\u{1F1EE}\u{1F1F3}' },
+ { value: 'te', label: 'Telugu', flag: '\u{1F1EE}\u{1F1F3}' },
+ { value: 'th', label: 'Thai', flag: '\u{1F1F9}\u{1F1ED}' },
+ { value: 'tr', label: 'Turkish', flag: '\u{1F1F9}\u{1F1F7}' },
+ { value: 'uk', label: 'Ukrainian', flag: '\u{1F1FA}\u{1F1E6}' },
+ { value: 'ur', label: 'Urdu', flag: '\u{1F1F5}\u{1F1F0}' },
+ { value: 'uz', label: 'Uzbek', flag: '\u{1F1FA}\u{1F1FF}' },
+ { value: 'vi', label: 'Vietnamese', flag: '\u{1F1FB}\u{1F1F3}' },
+ { value: 'zh', label: 'Chinese', flag: '\u{1F1E8}\u{1F1F3}' },
+ { value: 'zu', label: 'Zulu', flag: '\u{1F1FF}\u{1F1E6}' },
+] as const;
+
+/**
+ * Resolves a language code to a human-readable language name.
+ *
+ * - `'system'` → resolved from `systemLocale` via `Intl.DisplayNames` (e.g. "English", "Русский")
+ * - Known BCP-47 code → human name via `Intl.DisplayNames`
+ * - Fallback → returns the code itself
+ */
+export function resolveLanguageName(code: string, systemLocale?: string): string {
+ const effectiveCode = code === 'system' ? extractPrimaryLanguage(systemLocale ?? 'en') : code;
+
+ try {
+ const displayNames = new Intl.DisplayNames([effectiveCode], { type: 'language' });
+ const name = displayNames.of(effectiveCode);
+ if (name) {
+ return name.charAt(0).toUpperCase() + name.slice(1);
+ }
+ } catch {
+ // Intl.DisplayNames not available or invalid code — fall through
+ }
+
+ // Fallback: check our curated list
+ const option = AGENT_LANGUAGE_OPTIONS.find((o) => o.value === effectiveCode);
+ return option?.label ?? effectiveCode;
+}
+
+/** Extracts primary language subtag from a locale string (e.g. "en-US" → "en"). */
+function extractPrimaryLanguage(locale: string): string {
+ const dash = locale.indexOf('-');
+ return dash > 0 ? locale.slice(0, dash) : locale;
+}
diff --git a/src/shared/utils/rateLimitDetector.ts b/src/shared/utils/rateLimitDetector.ts
new file mode 100644
index 00000000..732b51aa
--- /dev/null
+++ b/src/shared/utils/rateLimitDetector.ts
@@ -0,0 +1,12 @@
+/**
+ * Detects rate limit messages from Claude.
+ */
+
+const RATE_LIMIT_SUBSTRING = "You've hit your limit";
+
+/**
+ * Returns true if the message text contains the rate limit indicator.
+ */
+export function isRateLimitMessage(text: string): boolean {
+ return text.includes(RATE_LIMIT_SUBSTRING);
+}
diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts
index 2f3e0b17..e9ad20b3 100644
--- a/test/main/ipc/teams.test.ts
+++ b/test/main/ipc/teams.test.ts
@@ -16,7 +16,9 @@ vi.mock('@preload/constants/ipcChannels', () => ({
TEAM_SEND_MESSAGE: 'team:sendMessage',
TEAM_REQUEST_REVIEW: 'team:requestReview',
TEAM_UPDATE_KANBAN: 'team:updateKanban',
+ TEAM_UPDATE_KANBAN_COLUMN_ORDER: 'team:updateKanbanColumnOrder',
TEAM_UPDATE_TASK_STATUS: 'team:updateTaskStatus',
+ TEAM_UPDATE_TASK_OWNER: 'team:updateTaskOwner',
TEAM_PROCESS_SEND: 'team:processSend',
TEAM_PROCESS_ALIVE: 'team:processAlive',
TEAM_ALIVE_LIST: 'team:aliveList',
@@ -28,6 +30,10 @@ vi.mock('@preload/constants/ipcChannels', () => ({
TEAM_START_TASK: 'team:startTask',
TEAM_GET_ALL_TASKS: 'team:getAllTasks',
TEAM_ADD_TASK_COMMENT: 'team:addTaskComment',
+ TEAM_ADD_MEMBER: 'team:addMember',
+ TEAM_REMOVE_MEMBER: 'team:removeMember',
+ TEAM_GET_PROJECT_BRANCH: 'team:getProjectBranch',
+ TEAM_GET_ATTACHMENTS: 'team:getAttachments',
}));
import {
@@ -54,8 +60,13 @@ import {
TEAM_START_TASK,
TEAM_UPDATE_CONFIG,
TEAM_UPDATE_KANBAN,
+ TEAM_UPDATE_KANBAN_COLUMN_ORDER,
TEAM_UPDATE_TASK_STATUS,
+ TEAM_ADD_MEMBER,
TEAM_ADD_TASK_COMMENT,
+ TEAM_GET_ATTACHMENTS,
+ TEAM_GET_PROJECT_BRANCH,
+ TEAM_REMOVE_MEMBER,
} from '../../../src/preload/constants/ipcChannels';
import {
initializeTeamHandlers,
@@ -82,6 +93,7 @@ describe('ipc teams handlers', () => {
createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })),
requestReview: vi.fn(async () => undefined),
updateKanban: vi.fn(async () => undefined),
+ updateKanbanColumnOrder: vi.fn(async () => undefined),
updateTaskStatus: vi.fn(async () => undefined),
startTask: vi.fn(async () => undefined),
addTaskComment: vi.fn(async () => ({
@@ -90,6 +102,8 @@ describe('ipc teams handlers', () => {
text: 'test comment',
createdAt: new Date().toISOString(),
})),
+ addMember: vi.fn(async () => undefined),
+ removeMember: vi.fn(async () => undefined),
};
const provisioningService = {
prepareForProvisioning: vi.fn(async () => ({
@@ -135,6 +149,7 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_SEND_MESSAGE)).toBe(true);
expect(handlers.has(TEAM_REQUEST_REVIEW)).toBe(true);
expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(true);
+ expect(handlers.has(TEAM_UPDATE_KANBAN_COLUMN_ORDER)).toBe(true);
expect(handlers.has(TEAM_UPDATE_TASK_STATUS)).toBe(true);
expect(handlers.has(TEAM_START_TASK)).toBe(true);
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(true);
@@ -148,6 +163,8 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(true);
expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(true);
expect(handlers.has(TEAM_ADD_TASK_COMMENT)).toBe(true);
+ expect(handlers.has(TEAM_ADD_MEMBER)).toBe(true);
+ expect(handlers.has(TEAM_REMOVE_MEMBER)).toBe(true);
});
it('returns success false on invalid sendMessage args', async () => {
@@ -212,6 +229,7 @@ describe('ipc teams handlers', () => {
it('dedups live lead replies when lead_session already has same text', async () => {
service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
+ config: { name: 'My Team' },
messages: [
{
from: 'team-lead',
@@ -301,6 +319,64 @@ describe('ipc teams handlers', () => {
});
});
+ describe('addMember', () => {
+ it('calls service on valid input', async () => {
+ const handler = handlers.get(TEAM_ADD_MEMBER)!;
+ const result = (await handler({} as never, 'my-team', {
+ name: 'alice',
+ role: 'developer',
+ })) as { success: boolean };
+ expect(result.success).toBe(true);
+ expect(service.addMember).toHaveBeenCalledWith('my-team', {
+ name: 'alice',
+ role: 'developer',
+ });
+ });
+
+ it('rejects invalid team name', async () => {
+ const handler = handlers.get(TEAM_ADD_MEMBER)!;
+ const result = (await handler({} as never, '../bad', {
+ name: 'alice',
+ })) as { success: boolean };
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects invalid member name', async () => {
+ const handler = handlers.get(TEAM_ADD_MEMBER)!;
+ const result = (await handler({} as never, 'my-team', {
+ name: '../bad',
+ })) as { success: boolean };
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects missing payload', async () => {
+ const handler = handlers.get(TEAM_ADD_MEMBER)!;
+ const result = (await handler({} as never, 'my-team', null)) as { success: boolean };
+ expect(result.success).toBe(false);
+ });
+ });
+
+ describe('removeMember', () => {
+ it('calls service on valid input', async () => {
+ const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
+ const result = (await handler({} as never, 'my-team', 'alice')) as { success: boolean };
+ expect(result.success).toBe(true);
+ expect(service.removeMember).toHaveBeenCalledWith('my-team', 'alice');
+ });
+
+ it('rejects invalid team name', async () => {
+ const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
+ const result = (await handler({} as never, '../bad', 'alice')) as { success: boolean };
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects invalid member name', async () => {
+ const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
+ const result = (await handler({} as never, 'my-team', '../bad')) as { success: boolean };
+ expect(result.success).toBe(false);
+ });
+ });
+
describe('createTeam prompt validation', () => {
it('accepts valid prompt in team create request', async () => {
const handler = handlers.get(TEAM_CREATE)!;
@@ -342,6 +418,7 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_SEND_MESSAGE)).toBe(false);
expect(handlers.has(TEAM_REQUEST_REVIEW)).toBe(false);
expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(false);
+ expect(handlers.has(TEAM_UPDATE_KANBAN_COLUMN_ORDER)).toBe(false);
expect(handlers.has(TEAM_UPDATE_TASK_STATUS)).toBe(false);
expect(handlers.has(TEAM_START_TASK)).toBe(false);
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(false);
@@ -355,5 +432,9 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(false);
expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(false);
expect(handlers.has(TEAM_ADD_TASK_COMMENT)).toBe(false);
+ expect(handlers.has(TEAM_ADD_MEMBER)).toBe(false);
+ expect(handlers.has(TEAM_REMOVE_MEMBER)).toBe(false);
+ expect(handlers.has(TEAM_GET_PROJECT_BRANCH)).toBe(false);
+ expect(handlers.has(TEAM_GET_ATTACHMENTS)).toBe(false);
});
});