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
|
└── 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
|
||||||
- `contexts/TabUIContext.tsx` - Per-tab UI state isolation
|
- `contexts/TabUIContext.tsx` - Per-tab UI state isolation
|
||||||
|
|
|
||||||
|
|
@ -1428,7 +1428,37 @@ async function handleKillProcess(
|
||||||
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) {
|
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) {
|
||||||
return { success: false, error: 'pid must be a positive integer' };
|
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(
|
async function handleAddTaskComment(
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import { TeamConfigReader } from './TeamConfigReader';
|
||||||
import { TeamInboxReader } from './TeamInboxReader';
|
import { TeamInboxReader } from './TeamInboxReader';
|
||||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||||
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||||
|
import { TeamTaskReader } from './TeamTaskReader';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
InboxMessage,
|
InboxMessage,
|
||||||
|
|
@ -37,6 +38,7 @@ import type {
|
||||||
TeamProvisioningPrepareResult,
|
TeamProvisioningPrepareResult,
|
||||||
TeamProvisioningProgress,
|
TeamProvisioningProgress,
|
||||||
TeamProvisioningState,
|
TeamProvisioningState,
|
||||||
|
TeamTask,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('Service:TeamProvisioning');
|
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}.`;
|
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 {
|
function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
||||||
const displayName = request.displayName?.trim() || request.teamName;
|
const displayName = request.displayName?.trim() || request.teamName;
|
||||||
const description = request.description?.trim() || 'No description';
|
const description = request.description?.trim() || 'No description';
|
||||||
|
|
@ -445,7 +479,8 @@ ${members}
|
||||||
|
|
||||||
function buildLaunchPrompt(
|
function buildLaunchPrompt(
|
||||||
request: TeamLaunchRequest,
|
request: TeamLaunchRequest,
|
||||||
members: TeamCreateRequest['members']
|
members: TeamCreateRequest['members'],
|
||||||
|
tasks: TeamTask[]
|
||||||
): string {
|
): string {
|
||||||
const membersBlock = buildMembersPrompt(members);
|
const membersBlock = buildMembersPrompt(members);
|
||||||
const userPromptBlock = request.prompt?.trim()
|
const userPromptBlock = request.prompt?.trim()
|
||||||
|
|
@ -455,17 +490,43 @@ function buildLaunchPrompt(
|
||||||
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
|
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
|
||||||
const languageInstruction = getAgentLanguageInstruction();
|
const languageInstruction = getAgentLanguageInstruction();
|
||||||
const agentBlockPolicy = buildAgentBlockUsagePolicy();
|
const agentBlockPolicy = buildAgentBlockUsagePolicy();
|
||||||
|
const taskBoardSnapshot = buildTaskBoardSnapshot(tasks);
|
||||||
|
|
||||||
const leadName = members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
|
const leadName = members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
|
||||||
const teamCtlOps = buildTeamCtlOpsInstructions(request.teamName, leadName);
|
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.
|
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.
|
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}
|
${userPromptBlock}
|
||||||
${languageInstruction}
|
${languageInstruction}
|
||||||
|
${taskBoardSnapshot}
|
||||||
Constraints:
|
Constraints:
|
||||||
- Do NOT call TeamDelete under any circumstances.
|
- Do NOT call TeamDelete under any circumstances.
|
||||||
- Do NOT use TodoWrite.
|
- 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.
|
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.
|
2) Spawn each existing member as a live teammate using the Task tool:
|
||||||
|
|
||||||
3) Spawn each existing member as a live teammate using the Task tool:
|
|
||||||
- team_name: "${request.teamName}"
|
- team_name: "${request.teamName}"
|
||||||
- name: the member's name
|
- name: the member's name
|
||||||
- subagent_type: "general-purpose"
|
- subagent_type: "general-purpose"
|
||||||
- prompt:
|
- IMPORTANT: Include each member's pending tasks in their spawn prompt so they resume work immediately.
|
||||||
You are {name}, a {role} on team "${request.teamName}".
|
Include the following agent-only instructions verbatim in each teammate's prompt:
|
||||||
${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:
|
|
||||||
|
|
||||||
${taskProtocol}
|
${taskProtocol}
|
||||||
|
|
||||||
${processRegistration}
|
${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.
|
Per-member spawn instructions:
|
||||||
- Prefer fewer, broader tasks over many micro-tasks.
|
${memberSpawnInstructions}
|
||||||
- Avoid duplicate notifications for the same assignment.
|
|
||||||
|
|
||||||
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:
|
Members:
|
||||||
${membersBlock}
|
${membersBlock}
|
||||||
|
|
@ -1105,7 +1161,16 @@ export class TeamProvisioningService {
|
||||||
this.activeByTeam.set(request.teamName, runId);
|
this.activeByTeam.set(request.teamName, runId);
|
||||||
run.onProgress(run.progress);
|
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>;
|
let child: ReturnType<typeof spawn>;
|
||||||
const { env: shellEnv, authSource } = await this.buildProvisioningEnv();
|
const { env: shellEnv, authSource } = await this.buildProvisioningEnv();
|
||||||
if (authSource === 'none') {
|
if (authSource === 'none') {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,8 @@
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
import { formatDistanceToNowStrict } from 'date-fns';
|
import { formatDistanceToNowStrict } from 'date-fns';
|
||||||
import { ExternalLink, Square, Terminal } from 'lucide-react';
|
import { ExternalLink, Square, Terminal } from 'lucide-react';
|
||||||
|
|
||||||
import type { TeamProcess } from '@shared/types';
|
import { MemberBadge } from './MemberBadge';
|
||||||
|
|
||||||
interface ProcessesSectionProps {
|
|
||||||
teamName: string;
|
|
||||||
processes: TeamProcess[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatShortTime(date: Date): string {
|
function formatShortTime(date: Date): string {
|
||||||
const distance = formatDistanceToNowStrict(date, { addSuffix: false });
|
const distance = formatDistanceToNowStrict(date, { addSuffix: false });
|
||||||
|
|
@ -27,11 +23,15 @@ function formatShortTime(date: Date): string {
|
||||||
.replace(' year', 'y');
|
.replace(' year', 'y');
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProcessesSection = ({
|
export const ProcessesSection = (): React.JSX.Element | null => {
|
||||||
teamName,
|
const teamName = useStore((s) => s.selectedTeamName);
|
||||||
processes,
|
const data = useStore((s) => s.selectedTeamData);
|
||||||
}: ProcessesSectionProps): React.JSX.Element => {
|
|
||||||
const sorted = [...processes].sort((a, b) => {
|
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 aAlive = !a.stoppedAt;
|
||||||
const bAlive = !b.stoppedAt;
|
const bAlive = !b.stoppedAt;
|
||||||
if (aAlive !== bAlive) return aAlive ? -1 : 1;
|
if (aAlive !== bAlive) return aAlive ? -1 : 1;
|
||||||
|
|
@ -53,9 +53,16 @@ export const ProcessesSection = ({
|
||||||
>
|
>
|
||||||
{/* Status indicator */}
|
{/* Status indicator */}
|
||||||
<span
|
<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'}
|
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 */}
|
{/* Icon + label — takes available space */}
|
||||||
<Terminal size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
<Terminal size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||||
|
|
@ -69,13 +76,21 @@ export const ProcessesSection = ({
|
||||||
{/* Port + URL inline — only when present */}
|
{/* Port + URL inline — only when present */}
|
||||||
{(proc.port != null || proc.url) && (
|
{(proc.port != null || proc.url) && (
|
||||||
<span className="min-w-0 truncate text-[var(--color-text-secondary)]">
|
<span className="min-w-0 truncate text-[var(--color-text-secondary)]">
|
||||||
{proc.port != null && `:${proc.port}`}
|
{proc.port != null && !proc.url && `:${proc.port}`}
|
||||||
{proc.port != null && proc.url && ' '}
|
{proc.url && (
|
||||||
{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>
|
</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">
|
<span className="ml-auto flex shrink-0 items-center gap-2">
|
||||||
{alive && (
|
{alive && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -99,8 +114,12 @@ export const ProcessesSection = ({
|
||||||
Open
|
Open
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<span className="font-mono text-[var(--color-text-muted)]">PID{proc.pid}</span>
|
||||||
{proc.registeredBy && (
|
{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 className="text-[var(--color-text-muted)]">{timeStr}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -863,7 +863,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
members={activeMembers}
|
members={activeMembers}
|
||||||
onFilterChange={setKanbanFilter}
|
onFilterChange={setKanbanFilter}
|
||||||
toolbarLeft={
|
toolbarLeft={
|
||||||
<div className="relative">
|
<div className="relative max-w-[240px]">
|
||||||
<Search
|
<Search
|
||||||
size={14}
|
size={14}
|
||||||
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
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}
|
badge={data.processes.filter((p) => !p.stoppedAt).length}
|
||||||
defaultOpen
|
defaultOpen
|
||||||
>
|
>
|
||||||
<ProcessesSection teamName={teamName} processes={data.processes} />
|
<ProcessesSection />
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
||||||
members={activeMembers}
|
members={activeMembers}
|
||||||
onClose={closeGlobalTaskDetail}
|
onClose={closeGlobalTaskDetail}
|
||||||
onOwnerChange={undefined}
|
onOwnerChange={undefined}
|
||||||
footerExtra={
|
headerExtra={
|
||||||
<button
|
<button
|
||||||
type="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)]"
|
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;
|
onScrollToTask?: (taskId: string) => void;
|
||||||
onOwnerChange?: (taskId: string, owner: string | null) => void;
|
onOwnerChange?: (taskId: string, owner: string | null) => void;
|
||||||
onViewChanges?: (taskId: string, filePath?: string) => void;
|
onViewChanges?: (taskId: string, filePath?: string) => void;
|
||||||
/** Extra content rendered in the dialog footer (e.g. "Open team" button). */
|
/** Extra content rendered in the dialog header (e.g. "Open team" button). */
|
||||||
footerExtra?: React.ReactNode;
|
headerExtra?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TaskDetailDialog = ({
|
export const TaskDetailDialog = ({
|
||||||
|
|
@ -72,7 +72,7 @@ export const TaskDetailDialog = ({
|
||||||
onScrollToTask,
|
onScrollToTask,
|
||||||
onOwnerChange,
|
onOwnerChange,
|
||||||
onViewChanges,
|
onViewChanges,
|
||||||
footerExtra,
|
headerExtra,
|
||||||
}: TaskDetailDialogProps): React.JSX.Element => {
|
}: TaskDetailDialogProps): React.JSX.Element => {
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
|
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
|
||||||
|
|
@ -171,6 +171,7 @@ export const TaskDetailDialog = ({
|
||||||
>
|
>
|
||||||
{statusLabel}
|
{statusLabel}
|
||||||
</span>
|
</span>
|
||||||
|
{headerExtra ? <div className="ml-auto mr-4">{headerExtra}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
<DialogTitle className="text-base">{currentTask.subject}</DialogTitle>
|
<DialogTitle className="text-base">{currentTask.subject}</DialogTitle>
|
||||||
{currentTask.activeForm ? (
|
{currentTask.activeForm ? (
|
||||||
|
|
@ -460,18 +461,9 @@ export const TaskDetailDialog = ({
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
{footerExtra ? (
|
<Button variant="outline" onClick={onClose}>
|
||||||
<div className="flex w-full items-center justify-between">
|
Close
|
||||||
{footerExtra}
|
</Button>
|
||||||
<Button variant="outline" onClick={onClose}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button variant="outline" onClick={onClose}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
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 { HunkDecision } from '@shared/types';
|
||||||
import type { FileChangeSummary } from '@shared/types/review';
|
import type { FileChangeSummary } from '@shared/types/review';
|
||||||
|
|
@ -92,7 +101,7 @@ function getFileStatus(
|
||||||
return 'mixed';
|
return 'mixed';
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileStatusIcon = ({ status }: { status: FileStatus }) => {
|
const FileStatusIcon = ({ status }: { status: FileStatus }): JSX.Element => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'accepted':
|
case 'accepted':
|
||||||
return <Check className="size-3 shrink-0 text-green-400" />;
|
return <Check className="size-3 shrink-0 text-green-400" />;
|
||||||
|
|
@ -116,6 +125,8 @@ const TreeItem = ({
|
||||||
viewedSet,
|
viewedSet,
|
||||||
onMarkViewed,
|
onMarkViewed,
|
||||||
onUnmarkViewed,
|
onUnmarkViewed,
|
||||||
|
collapsedFolders,
|
||||||
|
onToggleFolder,
|
||||||
}: {
|
}: {
|
||||||
node: TreeNode;
|
node: TreeNode;
|
||||||
selectedFilePath: string | null;
|
selectedFilePath: string | null;
|
||||||
|
|
@ -126,7 +137,9 @@ const TreeItem = ({
|
||||||
viewedSet?: Set<string>;
|
viewedSet?: Set<string>;
|
||||||
onMarkViewed?: (filePath: string) => void;
|
onMarkViewed?: (filePath: string) => void;
|
||||||
onUnmarkViewed?: (filePath: string) => void;
|
onUnmarkViewed?: (filePath: string) => void;
|
||||||
}) => {
|
collapsedFolders: Set<string>;
|
||||||
|
onToggleFolder: (fullPath: string) => void;
|
||||||
|
}): JSX.Element => {
|
||||||
if (node.isFile && node.file) {
|
if (node.isFile && node.file) {
|
||||||
const isSelected = node.file.filePath === selectedFilePath;
|
const isSelected = node.file.filePath === selectedFilePath;
|
||||||
const isActive = node.file.filePath === activeFilePath && !isSelected;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<button
|
||||||
className="flex items-center gap-2 px-2 py-1 text-xs text-text-muted"
|
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` }}
|
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>
|
<span className="truncate">{node.name}</span>
|
||||||
</div>
|
</button>
|
||||||
{[...node.children]
|
{isOpen &&
|
||||||
.sort((a, b) => {
|
[...node.children]
|
||||||
if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
|
.sort((a, b) => {
|
||||||
return a.name.localeCompare(b.name);
|
if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
|
||||||
})
|
return a.name.localeCompare(b.name);
|
||||||
.map((child) => (
|
})
|
||||||
<TreeItem
|
.map((child) => (
|
||||||
key={child.fullPath}
|
<TreeItem
|
||||||
node={child}
|
key={child.fullPath}
|
||||||
selectedFilePath={selectedFilePath}
|
node={child}
|
||||||
activeFilePath={activeFilePath}
|
selectedFilePath={selectedFilePath}
|
||||||
onSelectFile={onSelectFile}
|
activeFilePath={activeFilePath}
|
||||||
depth={depth + 1}
|
onSelectFile={onSelectFile}
|
||||||
hunkDecisions={hunkDecisions}
|
depth={depth + 1}
|
||||||
viewedSet={viewedSet}
|
hunkDecisions={hunkDecisions}
|
||||||
onMarkViewed={onMarkViewed}
|
viewedSet={viewedSet}
|
||||||
onUnmarkViewed={onUnmarkViewed}
|
onMarkViewed={onMarkViewed}
|
||||||
/>
|
onUnmarkViewed={onUnmarkViewed}
|
||||||
))}
|
collapsedFolders={collapsedFolders}
|
||||||
|
onToggleFolder={onToggleFolder}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</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 = ({
|
export const ReviewFileTree = ({
|
||||||
files,
|
files,
|
||||||
selectedFilePath,
|
selectedFilePath,
|
||||||
|
|
@ -224,9 +280,35 @@ export const ReviewFileTree = ({
|
||||||
onMarkViewed,
|
onMarkViewed,
|
||||||
onUnmarkViewed,
|
onUnmarkViewed,
|
||||||
activeFilePath,
|
activeFilePath,
|
||||||
}: ReviewFileTreeProps) => {
|
}: ReviewFileTreeProps): JSX.Element => {
|
||||||
const hunkDecisions = useStore((state) => state.hunkDecisions);
|
const hunkDecisions = useStore((state) => state.hunkDecisions);
|
||||||
const tree = useMemo(() => buildTree(files), [files]);
|
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
|
// Auto-scroll tree to active file when scroll-spy updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -263,6 +345,8 @@ export const ReviewFileTree = ({
|
||||||
viewedSet={viewedSet}
|
viewedSet={viewedSet}
|
||||||
onMarkViewed={onMarkViewed}
|
onMarkViewed={onMarkViewed}
|
||||||
onUnmarkViewed={onUnmarkViewed}
|
onUnmarkViewed={onUnmarkViewed}
|
||||||
|
collapsedFolders={collapsedFolders}
|
||||||
|
onToggleFolder={toggleFolder}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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()) {
|
if (!teamName.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -237,10 +237,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
teamName,
|
teamName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (taskId) {
|
|
||||||
set({ kanbanFilterQuery: `#${taskId}` });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
clearKanbanFilter: () => {
|
clearKanbanFilter: () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue