feat: enhance task management protocols and UI components

- Updated task management instructions in tasks.js and TeamProvisioningService.ts to emphasize the importance of moving tasks to in_progress before performing follow-up work.
- Added critical reminders in member briefing and task status protocols to ensure proper task handling and prevent issues with comment-driven work.
- Refactored TeamDetailView to improve tab management and data consistency after hot module replacement.
- Simplified MessagesPanel by integrating the StatusBlock component, enhancing the display of team status and pending replies.
- Cleaned up teamSlice by removing unnecessary functions and ensuring default model handling for team launch parameters.
This commit is contained in:
iliya 2026-03-14 00:09:05 +02:00
parent a6f9515966
commit c55787d6e1
6 changed files with 160 additions and 118 deletions

View file

@ -369,6 +369,9 @@ function buildMemberTaskProtocol(teamName) {
- Do NOT start multiple tasks at once unless the team lead explicitly directs parallel work.
3. Use MCP tool task_complete BEFORE sending your final reply:
{ teamName: "${teamName}", taskId: "<taskId>" }
- If a new task comment means you must do more real work on that same task, FIRST run task_start again before doing the follow-up work.
- After that follow-up work is finished, run task_complete again before your reply.
- Never do comment-driven implementation/fix work while the task is still shown as pending, review, completed, or approved.
4. If you are asked to review and the task is accepted, move it to APPROVED (not DONE) with MCP tool review_approve:
{ teamName: "${teamName}", taskId: "<taskId>", note?: "<optional note>", notifyOwner: true }
5. If review fails and changes are needed, use MCP tool review_request_changes:
@ -495,6 +498,7 @@ async function memberBriefing(context, memberName) {
const lines = [
`Member briefing for ${requestedMemberName} on team "${context.teamName}" (${context.teamName}).`,
`Role: ${role}.`,
`CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST move it to in_progress with task_start, THEN do the work, and when finished move it to done with task_complete. Never skip this reopen -> work -> done cycle.`,
`Team lead: ${leadName}.`,
buildMemberLanguageInstruction(config),
`You must NOT start work, claim tasks, or improvise task/process protocol before reading and following this briefing.`,

View file

@ -442,6 +442,7 @@ After member_briefing succeeds:
- Introduce yourself briefly (name and role) and confirm you are ready.
- Then wait for task assignments.
- When you later receive work or reconnect after a restart, use task_briefing as your compact queue view. Use task_get when you need the full task context before starting a pending/needsFix task or when the in_progress briefing details are not enough.
- CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST move it to in_progress with task_start, THEN do the work, and when finished move it to done with task_complete. Never skip this reopen -> work -> done cycle.
${buildTeammateAgentBlockReminder()}
${actionModeProtocol}`;
}
@ -474,6 +475,7 @@ ${actionModeProtocol}
- Before you start any needsFix or pending task, call task_get for that specific task.
- If you are the one about to do the implementation/fixes and the owner is missing or someone else, run task_set_owner to yourself immediately before task_start.
- Only then run task_start when you truly begin.
- If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on it, FIRST run task_start, then do the work, and when finished run task_complete again. Never skip this reopen -> work -> done cycle.
- If you have no tasks, wait for new assignments.`;
}
@ -513,6 +515,7 @@ ${actionModeProtocol}
- Before you start any needsFix or pending task, call task_get for that specific task.
- If you are the one about to do the implementation/fixes and the owner is missing or someone else, run task_set_owner to yourself immediately before task_start.
- Only then run task_start when you truly begin.
- If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on it, FIRST run task_start, then do the work, and when finished run task_complete again. Never skip this reopen -> work -> done cycle.
- If you have no tasks, wait for new assignments.`;
}
@ -603,6 +606,9 @@ function buildTaskStatusProtocol(teamName: string): string {
- Do NOT start multiple tasks at once unless the team lead explicitly directs parallel work.
3. Use MCP tool task_complete BEFORE sending your final reply:
{ teamName: "${teamName}", taskId: "<taskId>" }
- If a new task comment means you must do more real work on that same task, FIRST run task_start again before doing the follow-up work.
- After that follow-up work is finished, run task_complete again before your reply.
- Never do comment-driven implementation/fix work while the task is still shown as pending, review, completed, or approved.
4. If you are asked to review and the task is accepted, move it to APPROVED (not DONE) with MCP tool review_approve:
{ teamName: "${teamName}", taskId: "<taskId>", note?: "<optional note>", notifyOwner: true }
5. If review fails and changes are needed, use MCP tool review_request_changes:

View file

@ -303,6 +303,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
selectedContextPhase,
setSelectedContextPhase,
} = useTabUI();
const activeTabId = useStore((s) => s.activeTabId);
const isThisTabActive = tabId ? activeTabId === tabId : false;
const [isContextButtonHovered, setIsContextButtonHovered] = useState(false);
// Messages panel resize
@ -368,6 +370,18 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
void fetchDeletedTasks(teamName);
}, [teamName, selectTeam, fetchDeletedTasks]);
// Recovery: after HMR, all mounted TeamDetailView effects re-run simultaneously.
// With CSS display-toggle (all tabs stay mounted), the last selectTeam() call wins
// and other tabs get stuck with mismatched data (permanent skeleton).
// Re-trigger selectTeam when this tab becomes active and store data is stale.
const storedTeamName = data?.teamName;
useEffect(() => {
if (!isThisTabActive || !teamName || loading) return;
if (storedTeamName != null && storedTeamName !== teamName) {
void selectTeam(teamName);
}
}, [isThisTabActive, teamName, storedTeamName, loading, selectTeam]);
// Fetch active teams when launch dialog opens (for conflict warning)
useEffect(() => {
if (!launchDialogOpen) return;
@ -496,9 +510,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
[visibleContextTokens, lastAiGroupTotalTokens]
);
const activeTabId = useStore((s) => s.activeTabId);
const isThisTabActive = tabId ? activeTabId === tabId : false;
// Keep lead-session context fresh in the background while the team tab is active.
// This keeps the button value current even when the panel is closed.
// For offline teams: fetch once on mount so the percentage shows immediately.
@ -1097,6 +1108,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
launchParams?.model &&
(() => {
const MODEL_LABELS: Record<string, string> = {
default: 'Default',
opus: 'Opus 4.6',
sonnet: 'Sonnet 4.5',
haiku: 'Haiku 4.5',

View file

@ -6,7 +6,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { useStore } from '@renderer/store';
import { computePendingCrossTeamReplies } from '@renderer/utils/crossTeamPendingReplies';
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
@ -15,7 +14,6 @@ import {
CheckCheck,
ChevronsDownUp,
ChevronsUpDown,
ChevronRight,
MessageSquare,
PanelLeftClose,
PanelLeft,
@ -23,12 +21,11 @@ import {
X,
} from 'lucide-react';
import { ActiveTasksBlock } from '../activity/ActiveTasksBlock';
import { ActivityTimeline } from '../activity/ActivityTimeline';
import { PendingRepliesBlock } from '../activity/PendingRepliesBlock';
import { CollapsibleTeamSection } from '../CollapsibleTeamSection';
import { MessageComposer } from './MessageComposer';
import { MessagesFilterPopover } from './MessagesFilterPopover';
import { StatusBlock } from './StatusBlock';
import type { MessagesFilterState } from './MessagesFilterPopover';
import type { ActionMode } from './ActionModeSelector';
@ -110,15 +107,6 @@ export const MessagesPanel = ({
const [messagesFilterOpen, setMessagesFilterOpen] = useState(false);
const [messagesCollapsed, setMessagesCollapsed] = useState(true);
const [sidebarSearchVisible, setSidebarSearchVisible] = useState(false);
const [statusBlockCollapsed, setStatusBlockCollapsed] = useState(false);
const [pendingRepliesNowMs, setPendingRepliesNowMs] = useState(() => Date.now());
useEffect(() => {
const intervalId = window.setInterval(() => {
setPendingRepliesNowMs(Date.now());
}, 1000);
return () => window.clearInterval(intervalId);
}, []);
const filteredMessages = useMemo(() => {
return filterTeamMessages(messages, {
@ -148,28 +136,6 @@ export const MessagesPanel = ({
markAllRead(keys);
}, [filteredMessages, readSet, markAllRead]);
const pendingCrossTeamReplies = useMemo(
() => computePendingCrossTeamReplies(messages, pendingRepliesNowMs),
[messages, pendingRepliesNowMs]
);
/** Whether the Status block has any visible items (pending replies or active tasks). */
const hasStatusItems = useMemo(() => {
const hasPendingReplies = Object.keys(pendingRepliesByMember).some((name) =>
members.some((m) => m.name === name)
);
if (hasPendingReplies) return true;
if (pendingCrossTeamReplies.length > 0) return true;
const tMap = new Map(tasks.map((t) => [t.id, t]));
return members.some((m) => {
if (!m.currentTaskId) return false;
const task = tMap.get(m.currentTaskId);
if (task && (task.reviewState === 'approved' || task.status === 'completed')) return false;
return true;
});
}, [members, tasks, pendingRepliesByMember, pendingCrossTeamReplies.length]);
// Auto-clear pending replies when a member actually responds
useEffect(() => {
if (Object.keys(pendingRepliesByMember).length === 0) return;
@ -312,42 +278,14 @@ export const MessagesPanel = ({
onSend={handleSend}
onCrossTeamSend={handleCrossTeamSend}
/>
{/* Status block: button floats right (absolute, no layout impact);
expanded content renders full-width in normal flow. */}
{hasStatusItems && (
<>
<div className="relative h-0">
<button
type="button"
className="absolute -top-[19px] right-0 z-10 flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
onClick={() => setStatusBlockCollapsed((prev) => !prev)}
aria-label={statusBlockCollapsed ? 'Expand status' : 'Collapse status'}
>
<ChevronRight
size={12}
className={`shrink-0 transition-transform duration-150 ${statusBlockCollapsed ? '' : 'rotate-90'}`}
/>
Status
</button>
</div>
{!statusBlockCollapsed && (
<div className="mt-5">
<PendingRepliesBlock
members={members}
pendingRepliesByMember={pendingRepliesByMember}
pendingCrossTeamReplies={pendingCrossTeamReplies}
onMemberClick={onMemberClick}
/>
<ActiveTasksBlock
members={members}
tasks={tasks}
onMemberClick={onMemberClick}
onTaskClick={onTaskClick}
/>
</div>
)}
</>
)}
<StatusBlock
members={members}
tasks={tasks}
messages={messages}
pendingRepliesByMember={pendingRepliesByMember}
onMemberClick={onMemberClick}
onTaskClick={onTaskClick}
/>
<ActivityTimeline
messages={filteredMessages}
teamName={teamName}

View file

@ -0,0 +1,102 @@
import { useEffect, useMemo, useState } from 'react';
import { computePendingCrossTeamReplies } from '@renderer/utils/crossTeamPendingReplies';
import { ChevronRight } from 'lucide-react';
import { ActiveTasksBlock } from '../activity/ActiveTasksBlock';
import { PendingRepliesBlock } from '../activity/PendingRepliesBlock';
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
interface StatusBlockProps {
members: ResolvedTeamMember[];
tasks: TeamTaskWithKanban[];
messages: InboxMessage[];
pendingRepliesByMember: Record<string, number>;
onMemberClick?: (member: ResolvedTeamMember) => void;
onTaskClick?: (task: TeamTaskWithKanban) => void;
}
/**
* Self-contained status section that owns its own 1-second timer for
* cross-team pending reply TTL tracking. Isolates the timer-driven
* re-renders from the rest of MessagesPanel / ActivityTimeline so that
* text selection in messages is not disrupted.
*/
export const StatusBlock = ({
members,
tasks,
messages,
pendingRepliesByMember,
onMemberClick,
onTaskClick,
}: StatusBlockProps): React.JSX.Element | null => {
const [collapsed, setCollapsed] = useState(false);
const [nowMs, setNowMs] = useState(() => Date.now());
const pendingCrossTeamReplies = useMemo(
() => computePendingCrossTeamReplies(messages, nowMs),
[messages, nowMs]
);
/** Whether the Status block has any visible items. */
const hasItems = useMemo(() => {
const hasPendingReplies = Object.keys(pendingRepliesByMember).some((name) =>
members.some((m) => m.name === name)
);
if (hasPendingReplies) return true;
if (pendingCrossTeamReplies.length > 0) return true;
const tMap = new Map(tasks.map((t) => [t.id, t]));
return members.some((m) => {
if (!m.currentTaskId) return false;
const task = tMap.get(m.currentTaskId);
if (task && (task.reviewState === 'approved' || task.status === 'completed')) return false;
return true;
});
}, [members, tasks, pendingRepliesByMember, pendingCrossTeamReplies.length]);
// Only run the 1-second timer when the block actually has content to show.
useEffect(() => {
if (!hasItems) return;
const id = window.setInterval(() => setNowMs(Date.now()), 1000);
return () => window.clearInterval(id);
}, [hasItems]);
if (!hasItems) return null;
return (
<>
<div className="relative h-0">
<button
type="button"
className="absolute -top-[19px] right-0 z-10 flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
onClick={() => setCollapsed((prev) => !prev)}
aria-label={collapsed ? 'Expand status' : 'Collapse status'}
>
<ChevronRight
size={12}
className={`shrink-0 transition-transform duration-150 ${collapsed ? '' : 'rotate-90'}`}
/>
Status
</button>
</div>
{!collapsed && (
<div className="mt-5">
<PendingRepliesBlock
members={members}
pendingRepliesByMember={pendingRepliesByMember}
pendingCrossTeamReplies={pendingCrossTeamReplies}
onMemberClick={onMemberClick}
/>
<ActiveTasksBlock
members={members}
tasks={tasks}
onMemberClick={onMemberClick}
onTaskClick={onTaskClick}
/>
</div>
)}
</>
);
};

View file

@ -537,14 +537,6 @@ function saveLaunchParams(teamName: string, params: TeamLaunchParams): void {
}
}
function removeLaunchParams(teamName: string): void {
try {
localStorage.removeItem(LAUNCH_PARAMS_PREFIX + teamName);
} catch {
// ignore
}
}
/**
* Parse raw model string from TeamLaunchRequest back into base model + extended context flag.
* E.g. 'opus[1m]' { model: 'opus', extendedContext: true }
@ -1412,20 +1404,18 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
// Persist per-team launch params (model, effort, extended context)
const { model: baseModel, extendedContext } = parseModelString(request.model);
if (baseModel) {
const params: TeamLaunchParams = {
model: baseModel,
effort: request.effort,
extendedContext,
};
saveLaunchParams(request.teamName, params);
set((state) => ({
launchParamsByTeam: {
...state.launchParamsByTeam,
[request.teamName]: params,
},
}));
}
const params: TeamLaunchParams = {
model: baseModel || 'default',
effort: request.effort,
extendedContext,
};
saveLaunchParams(request.teamName, params);
set((state) => ({
launchParamsByTeam: {
...state.launchParamsByTeam,
[request.teamName]: params,
},
}));
set((state) => {
const nextRuns = { ...state.provisioningRuns };
@ -1545,28 +1535,18 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
// Persist per-team launch params (model, effort, extended context)
const { model: baseModel, extendedContext } = parseModelString(request.model);
if (baseModel) {
const params: TeamLaunchParams = {
model: baseModel,
effort: request.effort,
extendedContext,
};
saveLaunchParams(request.teamName, params);
set((state) => ({
launchParamsByTeam: {
...state.launchParamsByTeam,
[request.teamName]: params,
},
}));
} else {
// No model selected — clear stored params
removeLaunchParams(request.teamName);
set((state) => {
const updated = { ...state.launchParamsByTeam };
delete updated[request.teamName];
return { launchParamsByTeam: updated };
});
}
const params: TeamLaunchParams = {
model: baseModel || 'default',
effort: request.effort,
extendedContext,
};
saveLaunchParams(request.teamName, params);
set((state) => ({
launchParamsByTeam: {
...state.launchParamsByTeam,
[request.teamName]: params,
},
}));
set((state) => {
const nextRuns = { ...state.provisioningRuns };