diff --git a/.claude/rules/react.md b/.claude/rules/react.md index ba893c65..7e07988e 100644 --- a/.claude/rules/react.md +++ b/.claude/rules/react.md @@ -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 + +``` + +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 diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index ba20ba01..ea0693f0 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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( diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 0b2e4931..6e77e2e6 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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(); + 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; const { env: shellEnv, authSource } = await this.buildProvisioningEnv(); if (authSource === 'none') { diff --git a/src/renderer/components/team/ProcessesSection.tsx b/src/renderer/components/team/ProcessesSection.tsx index ff1738ba..ff6bd0e5 100644 --- a/src/renderer/components/team/ProcessesSection.tsx +++ b/src/renderer/components/team/ProcessesSection.tsx @@ -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 */} + > + {alive && ( + + )} + + {/* Icon + label — takes available space */} @@ -69,13 +76,21 @@ export const ProcessesSection = ({ {/* Port + URL inline — only when present */} {(proc.port != null || proc.url) && ( - {proc.port != null && `:${proc.port}`} - {proc.port != null && proc.url && ' '} - {proc.url} + {proc.port != null && !proc.url && `:${proc.port}`} + {proc.url && ( + + )} )} - {/* Right-aligned group: Kill button, Open button, author, time */} + {/* Right-aligned group: Kill button, Open button, member badge, PID, time */} {alive && ( - - ) : ( - - )} + diff --git a/src/renderer/components/team/review/ReviewFileTree.tsx b/src/renderer/components/team/review/ReviewFileTree.tsx index c8dac96a..44fb38d7 100644 --- a/src/renderer/components/team/review/ReviewFileTree.tsx +++ b/src/renderer/components/team/review/ReviewFileTree.tsx @@ -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 ; @@ -116,6 +125,8 @@ const TreeItem = ({ viewedSet, onMarkViewed, onUnmarkViewed, + collapsedFolders, + onToggleFolder, }: { node: TreeNode; selectedFilePath: string | null; @@ -126,7 +137,9 @@ const TreeItem = ({ viewedSet?: Set; onMarkViewed?: (filePath: string) => void; onUnmarkViewed?: (filePath: string) => void; -}) => { + collapsedFolders: Set; + 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 (
-
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}`} > - + + {node.name} -
- {[...node.children] - .sort((a, b) => { - if (a.isFile !== b.isFile) return a.isFile ? 1 : -1; - return a.name.localeCompare(b.name); - }) - .map((child) => ( - - ))} + + {isOpen && + [...node.children] + .sort((a, b) => { + if (a.isFile !== b.isFile) return a.isFile ? 1 : -1; + return a.name.localeCompare(b.name); + }) + .map((child) => ( + + ))}
); }; +function applyExpandAncestors(prev: Set, ancestors: string[]): Set { + 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>(() => 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} /> ))} diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index a274f2fa..5c0bff6b 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -199,7 +199,7 @@ export const createTeamSlice: StateCreator = (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 = (set, teamName, }); } - - if (taskId) { - set({ kanbanFilterQuery: `#${taskId}` }); - } }, clearKanbanFilter: () => {