feat: enhance React component data handling and improve task management features

- Updated `ProcessesSection` to directly access data from Zustand store, eliminating prop drilling for cleaner component structure.
- Added new guidelines in `react.md` for accessing data from the store over props to streamline component communication.
- Enhanced `TeamProvisioningService` to include task snapshots for team members, improving task management and visibility.
- Implemented notifications for killed processes, providing better feedback to team leads about process management actions.
- Refactored `TaskDetailDialog` and `GlobalTaskDetailDialog` to improve header content handling, enhancing user experience.
- Introduced folder collapsing functionality in `ReviewFileTree`, allowing for better navigation and organization of file structures.
This commit is contained in:
iliya 2026-02-25 18:07:03 +02:00
parent 877f214113
commit 1498350cb1
9 changed files with 290 additions and 87 deletions

View file

@ -46,5 +46,22 @@ components/
└── sidebar/ # Sidebar navigation
```
## Data Access: Store over Props
When data is available in the Zustand store, child components should read it directly via `useStore()` instead of receiving it through props. This avoids unnecessary prop drilling and keeps parent components clean.
```tsx
// Preferred — child reads from store
const ProcessesSection = () => {
const teamName = useStore((s) => s.selectedTeamName);
const data = useStore((s) => s.selectedTeamData);
// ...
};
// Avoid — parent drills store data as props
<ProcessesSection teamName={teamName} processes={data.processes} members={data.members} />
```
Only pass props when the data is NOT in the store (e.g. local state, computed values, callbacks).
## Contexts
- `contexts/TabUIContext.tsx` - Per-tab UI state isolation

View file

@ -1428,7 +1428,37 @@ async function handleKillProcess(
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) {
return { success: false, error: 'pid must be a positive integer' };
}
return wrapTeamHandler('killProcess', () => getTeamDataService().killProcess(vTeam.value!, pid));
return wrapTeamHandler('killProcess', async () => {
const tn = vTeam.value!;
const pidNum = pid;
// Read process label before killing (for notification message)
let processLabel = `PID ${pidNum}`;
try {
const data = await getTeamDataService().getTeamData(tn);
const proc = data.processes?.find((p) => p.pid === pidNum);
if (proc) {
processLabel = proc.label + (proc.port != null ? ` (:${proc.port})` : '');
}
} catch {
// best-effort label lookup
}
await getTeamDataService().killProcess(tn, pidNum);
// Notify the team lead about the killed process
const provisioning = getTeamProvisioningService();
if (provisioning.isTeamAlive(tn)) {
const message =
`Process "${processLabel}" (PID ${pidNum}) has been stopped by the user from the UI. ` +
`You may need to restart it if it was still needed.`;
try {
await provisioning.sendMessageToTeam(tn, message);
} catch {
logger.warn(`Failed to notify lead about killed process ${pidNum} in ${tn}`);
}
}
});
}
async function handleAddTaskComment(

View file

@ -26,6 +26,7 @@ import { TeamConfigReader } from './TeamConfigReader';
import { TeamInboxReader } from './TeamInboxReader';
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import { TeamTaskReader } from './TeamTaskReader';
import type {
InboxMessage,
@ -37,6 +38,7 @@ import type {
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamProvisioningState,
TeamTask,
} from '@shared/types';
const logger = createLogger('Service:TeamProvisioning');
@ -368,6 +370,38 @@ function getAgentLanguageInstruction(): string {
return `IMPORTANT: Communicate in ${languageName}. All messages, summaries, and task descriptions MUST be in ${languageName}.`;
}
/** Build a concise task snapshot for a specific member (pending/in_progress tasks only). */
function buildMemberTaskSnapshot(memberName: string, tasks: TeamTask[]): string {
const activeTasks = tasks.filter(
(t) =>
t.owner === memberName &&
(t.status === 'pending' || t.status === 'in_progress') &&
!t.id.startsWith('_internal')
);
if (activeTasks.length === 0) return '';
const lines = activeTasks.map((t) => {
const desc = t.description ? `${t.description.slice(0, 120)}` : '';
return ` - #${t.id} [${t.status}] ${t.subject}${desc}`;
});
return `\nYour pending tasks from last session (RESUME these immediately):\n${lines.join('\n')}\n`;
}
/** Build a full task board snapshot for the lead. */
function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
const active = tasks.filter(
(t) => (t.status === 'pending' || t.status === 'in_progress') && !t.id.startsWith('_internal')
);
if (active.length === 0) return '\nNo pending tasks on the board.\n';
const lines = active.map((t) => {
const owner = t.owner ? ` (owner: ${t.owner})` : ' (unassigned)';
const desc = t.description ? `${t.description.slice(0, 120)}` : '';
return ` - #${t.id} [${t.status}]${owner} ${t.subject}${desc}`;
});
return `\nCurrent task board (pending/in_progress):\n${lines.join('\n')}\n`;
}
function buildProvisioningPrompt(request: TeamCreateRequest): string {
const displayName = request.displayName?.trim() || request.teamName;
const description = request.description?.trim() || 'No description';
@ -445,7 +479,8 @@ ${members}
function buildLaunchPrompt(
request: TeamLaunchRequest,
members: TeamCreateRequest['members']
members: TeamCreateRequest['members'],
tasks: TeamTask[]
): string {
const membersBlock = buildMembersPrompt(members);
const userPromptBlock = request.prompt?.trim()
@ -455,17 +490,43 @@ function buildLaunchPrompt(
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
const languageInstruction = getAgentLanguageInstruction();
const agentBlockPolicy = buildAgentBlockUsagePolicy();
const taskBoardSnapshot = buildTaskBoardSnapshot(tasks);
const leadName = members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
const teamCtlOps = buildTeamCtlOpsInstructions(request.teamName, leadName);
// Build per-member task snapshots to include in each teammate's spawn prompt
const memberTaskBlocks = new Map<string, string>();
for (const m of members) {
const snapshot = buildMemberTaskSnapshot(m.name, tasks);
if (snapshot) memberTaskBlocks.set(m.name, snapshot);
}
// Build the teammate spawn prompt template with member-specific task injection
const memberSpawnInstructions = members
.map((m) => {
const taskBlock = memberTaskBlocks.get(m.name) || '';
const resumeInstruction = taskBlock
? `Include these tasks in the prompt for ${m.name} so they know what to resume:${taskBlock}`
: `${m.name} has no pending tasks — tell them to wait for new assignments.`;
return ` For "${m.name}":
- prompt:
You are ${m.name}, a ${m.role || 'team member'} on team "${request.teamName}".
${languageInstruction}
The team has been reconnected after a restart.${taskBlock}
${taskBlock ? 'Resume your pending tasks immediately — start with in_progress tasks first, then pending.' : 'Wait for new task assignments.'}
Note: ${resumeInstruction}`;
})
.join('\n\n');
return `You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
You are "${leadName}", the team lead.
Goal: Reconnect with existing team "${request.teamName}".
Goal: Reconnect with existing team "${request.teamName}" and resume pending work.
${userPromptBlock}
${languageInstruction}
${taskBoardSnapshot}
Constraints:
- Do NOT call TeamDelete under any circumstances.
- Do NOT use TodoWrite.
@ -491,28 +552,23 @@ Steps (execute in this exact order):
1) Read team config at ~/.claude/teams/${request.teamName}/config.json understand current team state.
2) Read tasks from ~/.claude/tasks/${request.teamName}/ (JSON files) and kanban state from ~/.claude/teams/${request.teamName}/kanban-state.json understand pending work.
3) Spawn each existing member as a live teammate using the Task tool:
2) Spawn each existing member as a live teammate using the Task tool:
- team_name: "${request.teamName}"
- name: the member's name
- subagent_type: "general-purpose"
- prompt:
You are {name}, a {role} on team "${request.teamName}".
${languageInstruction}
The team has been reconnected. Introduce yourself briefly (name and role) and confirm you are ready.
Then resume any pending work you own (if any) and wait for new assignments.
Include the following agent-only instructions verbatim in the prompt:
- IMPORTANT: Include each member's pending tasks in their spawn prompt so they resume work immediately.
Include the following agent-only instructions verbatim in each teammate's prompt:
${taskProtocol}
${processRegistration}
4) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked create tasks on the team board.
- Prefer fewer, broader tasks over many micro-tasks.
- Avoid duplicate notifications for the same assignment.
Per-member spawn instructions:
${memberSpawnInstructions}
5) After all steps, output a short summary.
3) After spawning all members, check the task board. If any pending tasks are unassigned, assign them to appropriate members using teamctl.
4) After all steps, output a short summary of reconnected members and resumed tasks.
Members:
${membersBlock}
@ -1105,7 +1161,16 @@ export class TeamProvisioningService {
this.activeByTeam.set(request.teamName, runId);
run.onProgress(run.progress);
const prompt = buildLaunchPrompt(request, expectedMemberSpecs);
// Read existing tasks to include in teammate prompts for work resumption
const taskReader = new TeamTaskReader();
let existingTasks: TeamTask[] = [];
try {
existingTasks = await taskReader.getTasks(request.teamName);
} catch (error) {
logger.warn(`[${request.teamName}] Failed to read tasks for launch prompt: ${String(error)}`);
}
const prompt = buildLaunchPrompt(request, expectedMemberSpecs, existingTasks);
let child: ReturnType<typeof spawn>;
const { env: shellEnv, authSource } = await this.buildProvisioningEnv();
if (authSource === 'none') {

View file

@ -1,12 +1,8 @@
import { useStore } from '@renderer/store';
import { formatDistanceToNowStrict } from 'date-fns';
import { ExternalLink, Square, Terminal } from 'lucide-react';
import type { TeamProcess } from '@shared/types';
interface ProcessesSectionProps {
teamName: string;
processes: TeamProcess[];
}
import { MemberBadge } from './MemberBadge';
function formatShortTime(date: Date): string {
const distance = formatDistanceToNowStrict(date, { addSuffix: false });
@ -27,11 +23,15 @@ function formatShortTime(date: Date): string {
.replace(' year', 'y');
}
export const ProcessesSection = ({
teamName,
processes,
}: ProcessesSectionProps): React.JSX.Element => {
const sorted = [...processes].sort((a, b) => {
export const ProcessesSection = (): React.JSX.Element | null => {
const teamName = useStore((s) => s.selectedTeamName);
const data = useStore((s) => s.selectedTeamData);
if (!teamName || !data?.processes?.length) return null;
const memberColorMap = new Map(data.members.map((m) => [m.name, m.color]));
const sorted = [...data.processes].sort((a, b) => {
const aAlive = !a.stoppedAt;
const bAlive = !b.stoppedAt;
if (aAlive !== bAlive) return aAlive ? -1 : 1;
@ -53,9 +53,16 @@ export const ProcessesSection = ({
>
{/* Status indicator */}
<span
className={`inline-block size-2 shrink-0 rounded-full ${alive ? 'bg-emerald-400' : 'bg-zinc-500'}`}
className="relative inline-flex size-2 shrink-0"
title={alive ? 'Running' : 'Stopped'}
/>
>
{alive && (
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
)}
<span
className={`relative inline-flex size-2 rounded-full ${alive ? 'bg-emerald-400' : 'bg-zinc-500'}`}
/>
</span>
{/* Icon + label — takes available space */}
<Terminal size={12} className="shrink-0 text-[var(--color-text-muted)]" />
@ -69,13 +76,21 @@ export const ProcessesSection = ({
{/* Port + URL inline — only when present */}
{(proc.port != null || proc.url) && (
<span className="min-w-0 truncate text-[var(--color-text-secondary)]">
{proc.port != null && `:${proc.port}`}
{proc.port != null && proc.url && ' '}
{proc.url}
{proc.port != null && !proc.url && `:${proc.port}`}
{proc.url && (
<button
type="button"
className="text-[var(--color-text-secondary)] underline decoration-dotted underline-offset-2 transition-colors hover:text-blue-400"
onClick={() => void window.electronAPI.openExternal(proc.url!)}
title={proc.url}
>
{proc.url}
</button>
)}
</span>
)}
{/* Right-aligned group: Kill button, Open button, author, time */}
{/* Right-aligned group: Kill button, Open button, member badge, PID, time */}
<span className="ml-auto flex shrink-0 items-center gap-2">
{alive && (
<button
@ -99,8 +114,12 @@ export const ProcessesSection = ({
Open
</button>
)}
<span className="font-mono text-[var(--color-text-muted)]">PID{proc.pid}</span>
{proc.registeredBy && (
<span className="text-[var(--color-text-muted)]">{proc.registeredBy}</span>
<MemberBadge
name={proc.registeredBy}
color={memberColorMap.get(proc.registeredBy)}
/>
)}
<span className="text-[var(--color-text-muted)]">{timeStr}</span>
</span>

View file

@ -863,7 +863,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
members={activeMembers}
onFilterChange={setKanbanFilter}
toolbarLeft={
<div className="relative">
<div className="relative max-w-[240px]">
<Search
size={14}
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
@ -1000,7 +1000,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
badge={data.processes.filter((p) => !p.stoppedAt).length}
defaultOpen
>
<ProcessesSection teamName={teamName} processes={data.processes} />
<ProcessesSection />
</CollapsibleTeamSection>
)}

View file

@ -63,7 +63,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
members={activeMembers}
onClose={closeGlobalTaskDetail}
onOwnerChange={undefined}
footerExtra={
headerExtra={
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"

View file

@ -57,8 +57,8 @@ interface TaskDetailDialogProps {
onScrollToTask?: (taskId: string) => void;
onOwnerChange?: (taskId: string, owner: string | null) => void;
onViewChanges?: (taskId: string, filePath?: string) => void;
/** Extra content rendered in the dialog footer (e.g. "Open team" button). */
footerExtra?: React.ReactNode;
/** Extra content rendered in the dialog header (e.g. "Open team" button). */
headerExtra?: React.ReactNode;
}
export const TaskDetailDialog = ({
@ -72,7 +72,7 @@ export const TaskDetailDialog = ({
onScrollToTask,
onOwnerChange,
onViewChanges,
footerExtra,
headerExtra,
}: TaskDetailDialogProps): React.JSX.Element => {
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
@ -171,6 +171,7 @@ export const TaskDetailDialog = ({
>
{statusLabel}
</span>
{headerExtra ? <div className="ml-auto mr-4">{headerExtra}</div> : null}
</div>
<DialogTitle className="text-base">{currentTask.subject}</DialogTitle>
{currentTask.activeForm ? (
@ -460,18 +461,9 @@ export const TaskDetailDialog = ({
</CollapsibleTeamSection>
<DialogFooter>
{footerExtra ? (
<div className="flex w-full items-center justify-between">
{footerExtra}
<Button variant="outline" onClick={onClose}>
Close
</Button>
</div>
) : (
<Button variant="outline" onClick={onClose}>
Close
</Button>
)}
<Button variant="outline" onClick={onClose}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View file

@ -1,8 +1,17 @@
import { useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { Check, Circle, CircleDot, File, FolderOpen, X as XIcon } from 'lucide-react';
import {
Check,
ChevronRight,
Circle,
CircleDot,
File,
Folder,
FolderOpen,
X as XIcon,
} from 'lucide-react';
import type { HunkDecision } from '@shared/types';
import type { FileChangeSummary } from '@shared/types/review';
@ -92,7 +101,7 @@ function getFileStatus(
return 'mixed';
}
const FileStatusIcon = ({ status }: { status: FileStatus }) => {
const FileStatusIcon = ({ status }: { status: FileStatus }): JSX.Element => {
switch (status) {
case 'accepted':
return <Check className="size-3 shrink-0 text-green-400" />;
@ -116,6 +125,8 @@ const TreeItem = ({
viewedSet,
onMarkViewed,
onUnmarkViewed,
collapsedFolders,
onToggleFolder,
}: {
node: TreeNode;
selectedFilePath: string | null;
@ -126,7 +137,9 @@ const TreeItem = ({
viewedSet?: Set<string>;
onMarkViewed?: (filePath: string) => void;
onUnmarkViewed?: (filePath: string) => void;
}) => {
collapsedFolders: Set<string>;
onToggleFolder: (fullPath: string) => void;
}): JSX.Element => {
if (node.isFile && node.file) {
const isSelected = node.file.filePath === selectedFilePath;
const isActive = node.file.filePath === activeFilePath && !isSelected;
@ -184,38 +197,81 @@ const TreeItem = ({
);
}
const isOpen = !collapsedFolders.has(node.fullPath);
const FolderIcon = isOpen ? FolderOpen : Folder;
return (
<div>
<div
className="flex items-center gap-2 px-2 py-1 text-xs text-text-muted"
<button
type="button"
onClick={() => onToggleFolder(node.fullPath)}
className="flex w-full cursor-pointer items-center gap-1.5 px-2 py-1 text-xs text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
style={{ paddingLeft: `${depth * 12 + 8}px` }}
aria-label={isOpen ? `Collapse ${node.name}` : `Expand ${node.name}`}
>
<FolderOpen className="size-3.5 shrink-0" />
<ChevronRight
size={12}
className={cn('shrink-0 transition-transform duration-150', isOpen && 'rotate-90')}
/>
<FolderIcon className="size-3.5 shrink-0" />
<span className="truncate">{node.name}</span>
</div>
{[...node.children]
.sort((a, b) => {
if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
return a.name.localeCompare(b.name);
})
.map((child) => (
<TreeItem
key={child.fullPath}
node={child}
selectedFilePath={selectedFilePath}
activeFilePath={activeFilePath}
onSelectFile={onSelectFile}
depth={depth + 1}
hunkDecisions={hunkDecisions}
viewedSet={viewedSet}
onMarkViewed={onMarkViewed}
onUnmarkViewed={onUnmarkViewed}
/>
))}
</button>
{isOpen &&
[...node.children]
.sort((a, b) => {
if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
return a.name.localeCompare(b.name);
})
.map((child) => (
<TreeItem
key={child.fullPath}
node={child}
selectedFilePath={selectedFilePath}
activeFilePath={activeFilePath}
onSelectFile={onSelectFile}
depth={depth + 1}
hunkDecisions={hunkDecisions}
viewedSet={viewedSet}
onMarkViewed={onMarkViewed}
onUnmarkViewed={onUnmarkViewed}
collapsedFolders={collapsedFolders}
onToggleFolder={onToggleFolder}
/>
))}
</div>
);
};
function applyExpandAncestors(prev: Set<string>, ancestors: string[]): Set<string> {
const collapsedAncestors = ancestors.filter((a) => prev.has(a));
if (collapsedAncestors.length === 0) return prev;
const next = new Set(prev);
for (const a of collapsedAncestors) {
next.delete(a);
}
return next;
}
function getAncestorFolderPaths(tree: TreeNode[], filePath: string): string[] {
const paths: string[] = [];
function walk(nodes: TreeNode[], ancestors: string[]): boolean {
for (const node of nodes) {
if (node.isFile && node.file?.filePath === filePath) {
paths.push(...ancestors);
return true;
}
if (!node.isFile) {
if (walk(node.children, [...ancestors, node.fullPath])) return true;
}
}
return false;
}
walk(tree, []);
return paths;
}
export const ReviewFileTree = ({
files,
selectedFilePath,
@ -224,9 +280,35 @@ export const ReviewFileTree = ({
onMarkViewed,
onUnmarkViewed,
activeFilePath,
}: ReviewFileTreeProps) => {
}: ReviewFileTreeProps): JSX.Element => {
const hunkDecisions = useStore((state) => state.hunkDecisions);
const tree = useMemo(() => buildTree(files), [files]);
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(() => new Set());
const toggleFolder = useCallback((fullPath: string) => {
setCollapsedFolders((prev) => {
const next = new Set(prev);
if (next.has(fullPath)) {
next.delete(fullPath);
} else {
next.add(fullPath);
}
return next;
});
}, []);
// Auto-expand parent folders when a file is selected or becomes active
useEffect(() => {
const targetPath = selectedFilePath ?? activeFilePath;
if (!targetPath) return;
const ancestors = getAncestorFolderPaths(tree, targetPath);
if (ancestors.length === 0) return;
queueMicrotask(() => {
setCollapsedFolders((prev) => applyExpandAncestors(prev, ancestors));
});
}, [selectedFilePath, activeFilePath, tree]);
// Auto-scroll tree to active file when scroll-spy updates
useEffect(() => {
@ -263,6 +345,8 @@ export const ReviewFileTree = ({
viewedSet={viewedSet}
onMarkViewed={onMarkViewed}
onUnmarkViewed={onUnmarkViewed}
collapsedFolders={collapsedFolders}
onToggleFolder={toggleFolder}
/>
))}
</div>

View file

@ -199,7 +199,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
});
},
openTeamTab: (teamName: string, projectPath?: string, taskId?: string) => {
openTeamTab: (teamName: string, projectPath?: string, _taskId?: string) => {
if (!teamName.trim()) {
return;
}
@ -237,10 +237,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
teamName,
});
}
if (taskId) {
set({ kanbanFilterQuery: `#${taskId}` });
}
},
clearKanbanFilter: () => {