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:
parent
877f214113
commit
1498350cb1
9 changed files with 290 additions and 87 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)]"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue