diff --git a/README.md b/README.md index a2bf10d5..37712043 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ A new approach to task management with AI agents. - **Attach code context** — reference files or snippets in messages, like in Cursor - **Notification system** — configurable alerts when tasks complete, agents need attention, or errors occur - **MCP integration** — supports the built-in `mcp-server` (see [mcp-server folder](./mcp-server)) for integrating external tools and extensible agent plugins out of the box - +- **Post-compact context recovery** — when Claude compresses its context, the app restores the key team-management instructions so kanban/task-board coordination stays consistent and important operational context is not lost ## Installation diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 8a098bb8..d41c84e6 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -3163,9 +3163,16 @@ export class TeamProvisioningService { if (run.provisioningComplete) { // If this was a post-compact reminder turn completing, clear in-flight and suppress flags. + // Preserve pendingPostCompactReminder if re-armed by a compact_boundary during this turn. if (run.postCompactReminderInFlight) { - clearPostCompactReminderState(run); - logger.info(`[${run.teamName}] post-compact reminder turn completed`); + const hadPendingRearm = run.pendingPostCompactReminder; + run.postCompactReminderInFlight = false; + run.suppressPostCompactReminderOutput = false; + logger.info( + `[${run.teamName}] post-compact reminder turn completed${ + hadPendingRearm ? ' (follow-up reminder pending from re-compact)' : '' + }` + ); } this.setLeadActivity(run, 'idle'); @@ -3227,11 +3234,13 @@ export class TeamProvisioningService { this.cleanupRun(run); } else if (run.provisioningComplete) { // Post-provisioning error: process alive, waiting for input. - // Drop post-compact reminder on error (strict drop-after-attempt policy). - if (run.postCompactReminderInFlight) { + // Always clear all post-compact reminder state on error — prevents a stale pending + // reminder from firing on the next unrelated successful turn. + if (run.pendingPostCompactReminder || run.postCompactReminderInFlight) { + const wasInFlight = run.postCompactReminderInFlight; clearPostCompactReminderState(run); logger.warn( - `[${run.teamName}] post-compact reminder turn errored — dropping (strict policy)` + `[${run.teamName}] post-compact reminder ${wasInFlight ? 'turn errored' : 'pending dropped'} — clearing (strict policy)` ); } this.setLeadActivity(run, 'idle'); @@ -3273,14 +3282,15 @@ export class TeamProvisioningService { ); // Schedule post-compact context reinjection on next idle. - // Guard: only set if provisioning is complete and no reminder is already pending/in-flight. - if ( - run.provisioningComplete && - !run.pendingPostCompactReminder && - !run.postCompactReminderInFlight - ) { + // If a reminder is already in-flight, re-arm pending so a follow-up fires after it completes. + // This handles the case where the reminder prompt itself triggers another compaction. + if (run.provisioningComplete && !run.pendingPostCompactReminder) { run.pendingPostCompactReminder = true; - logger.info(`[${run.teamName}] post-compact reminder scheduled for next idle`); + logger.info( + `[${run.teamName}] post-compact reminder scheduled for next idle${ + run.postCompactReminderInFlight ? ' (re-armed during in-flight reminder)' : '' + }` + ); } } } @@ -3333,16 +3343,43 @@ export class TeamProvisioningService { return; } - const leadName = - run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; - const isSolo = run.request.members.length === 0; + // Read current team config for up-to-date members (may have changed since launch). + let currentMembers: TeamCreateRequest['members'] = run.request.members; + let leadName = 'team-lead'; + try { + const config = await this.configReader.getConfig(run.teamName); + if (config?.members) { + const configLead = config.members.find((m) => m?.agentType === 'team-lead'); + leadName = configLead?.name?.trim() || 'team-lead'; + // Convert config members (excluding lead) to TeamCreateRequest member format. + currentMembers = config.members + .filter((m) => m?.agentType !== 'team-lead' && m?.name) + .map((m) => ({ + name: m.name!, + role: m.role ?? undefined, + })); + } else { + leadName = + run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || + 'team-lead'; + } + } catch { + // Fallback to launch-time members if config is unavailable. + leadName = + run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || + 'team-lead'; + logger.warn( + `[${run.teamName}] post-compact reminder: config unavailable, using launch-time members` + ); + } + const isSolo = currentMembers.length === 0; // Build persistent lead context. const persistentContext = buildPersistentLeadContext({ teamName: run.teamName, leadName, isSolo, - members: run.request.members, + members: currentMembers, compact: true, }); diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 63a2b754..480f4134 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -3,16 +3,13 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList'; import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay'; -import { Button } from '@renderer/components/ui/button'; import { Dialog, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, } from '@renderer/components/ui/dialog'; -import { Input } from '@renderer/components/ui/input'; import { Label } from '@renderer/components/ui/label'; import { MemberSelect } from '@renderer/components/ui/MemberSelect'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; @@ -86,7 +83,6 @@ export const SendMessageDialog = ({ const [member, setMember] = useState(''); const textDraft = useDraftPersistence({ key: `sendMessage:${teamName}:text` }); const chipDraft = useChipDraftPersistence(`sendMessage:${teamName}:chips`); - const [summary, setSummary] = useState(''); const prevOpenRef = useRef(false); const prevResultRef = useRef(null); @@ -115,7 +111,6 @@ export const SendMessageDialog = ({ useEffect(() => { if (open && !prevOpenRef.current) { setMember(defaultRecipient ?? ''); - setSummary(''); setQuote(quotedMessage); setQuoteExpanded(false); prevResultRef.current = lastResult; @@ -145,7 +140,6 @@ export const SendMessageDialog = ({ if (lastResult && lastResult !== prevResultRef.current) { prevResultRef.current = lastResult; setMember(''); - setSummary(''); setPendingAutoClose(true); } }, [open, lastResult]); @@ -200,15 +194,7 @@ export const SendMessageDialog = ({ const handleSubmit = (): void => { if (!canSend) return; - // TODO: Research whether duplicating message as summary is correct — the team lead - // may only see the Summary field and not the full Message body. Need to verify. - const effectiveSummary = summary.trim() || trimmedText; - onSend( - member.trim(), - finalText, - effectiveSummary, - attachments.length > 0 ? attachments : undefined - ); + onSend(member.trim(), finalText, trimmedText, attachments.length > 0 ? attachments : undefined); textDraft.clearDraft(); chipDraft.clearChipDraft(); clearAttachments(); @@ -269,7 +255,7 @@ export const SendMessageDialog = ({ return ( - -
- - setSummary(e.target.value)} - /> -

- Shown as notification preview. Team lead also sees this for peer messages. -

-
- - - -
); diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 9f38bb8a..3808d4a0 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -59,7 +59,7 @@ export const MemberCard = ({ className="group relative cursor-pointer rounded px-2 py-1.5" style={{ borderLeft: `3px solid ${colors.border}`, - backgroundColor: colors.badge, + background: `linear-gradient(to right, ${colors.badge}, transparent)`, }} title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined} role="button" diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 14d426f0..d3094d96 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -1,4 +1,5 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { useStore } from '@renderer/store'; import { MemberCard } from './MemberCard'; @@ -32,6 +33,8 @@ export const MemberList = ({ onAssignTask, onOpenTask, }: MemberListProps): React.JSX.Element => { + const sidebarCollapsed = useStore((s) => s.sidebarCollapsed); + const gridClass = sidebarCollapsed ? 'grid grid-cols-2 gap-1' : 'grid grid-cols-1 gap-1'; const activeMembers = members .filter((m) => !m.removedAt) .sort((a, b) => { @@ -75,14 +78,16 @@ export const MemberList = ({ }; return ( -
- {activeMembers.map((member) => renderCard(member, false))} +
+
{activeMembers.map((member) => renderCard(member, false))}
{removedMembers.length > 0 && ( <>
Removed ({removedMembers.length})
- {removedMembers.map((member) => renderCard(member, true))} +
+ {removedMembers.map((member) => renderCard(member, true))} +
)}
diff --git a/test/main/services/team/TeamProvisioningServicePostCompact.test.ts b/test/main/services/team/TeamProvisioningServicePostCompact.test.ts index 295e9150..78d66bea 100644 --- a/test/main/services/team/TeamProvisioningServicePostCompact.test.ts +++ b/test/main/services/team/TeamProvisioningServicePostCompact.test.ts @@ -155,7 +155,7 @@ describe('TeamProvisioningService post-compact lifecycle', () => { await svc.cancelProvisioning(runId); }); - it('compact_boundary does NOT set pending when reminder is already in-flight', async () => { + it('compact_boundary re-arms pending when reminder is already in-flight', async () => { const { svc, run, runId } = await setupRunningTeam('compact-test-3'); run.postCompactReminderInFlight = true; @@ -165,8 +165,8 @@ describe('TeamProvisioningService post-compact lifecycle', () => { compact_metadata: { trigger: 'auto' }, }); - // Should NOT be set because in-flight - expect(run.pendingPostCompactReminder).toBe(false); + // Should be re-armed even during in-flight — follow-up reminder after current completes + expect(run.pendingPostCompactReminder).toBe(true); run.postCompactReminderInFlight = false; await svc.cancelProvisioning(runId); @@ -365,4 +365,109 @@ describe('TeamProvisioningService post-compact lifecycle', () => { // Should NOT re-arm pending (strict drop) expect(run.pendingPostCompactReminder).toBe(false); }); + + it('result.error clears pending even when NOT in-flight (no stale pending survives)', async () => { + const { svc, run } = await setupRunningTeam('compact-test-13'); + // pending set but reminder never started (no in-flight) + run.pendingPostCompactReminder = true; + run.postCompactReminderInFlight = false; + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + (svc as any).handleStreamJsonMessage(run, { + type: 'result', + subtype: 'error', + error: 'some error', + }); + + warnSpy.mockRestore(); + + // Pending must be cleared — must not fire on a later unrelated result.success + expect(run.pendingPostCompactReminder).toBe(false); + expect(run.postCompactReminderInFlight).toBe(false); + }); + + it('compact_boundary during in-flight produces follow-up reminder after current completes', async () => { + const { svc, run, runId, writeSpy } = await setupRunningTeam('compact-test-14'); + + // Start first reminder + run.pendingPostCompactReminder = true; + writeSpy.mockClear(); + await (svc as any).injectPostCompactReminder(run); + expect(run.postCompactReminderInFlight).toBe(true); + expect(run.pendingPostCompactReminder).toBe(false); + + // Compact fires while first reminder is in-flight + (svc as any).handleStreamJsonMessage(run, { + type: 'system', + subtype: 'compact_boundary', + compact_metadata: { trigger: 'auto' }, + }); + // Re-armed + expect(run.pendingPostCompactReminder).toBe(true); + + // First reminder completes (result.success). + // The success handler clears in-flight, preserves pending, transitions to idle, + // then the injection hook fires immediately because pending=true && !inFlight. + // So after success, a NEW reminder is already in-flight. + writeSpy.mockClear(); + (svc as any).handleStreamJsonMessage(run, { + type: 'result', + subtype: 'success', + result: {}, + }); + + // Allow the void async injection to run + await new Promise((r) => setTimeout(r, 50)); + + // A follow-up reminder was triggered: in-flight again, pending consumed + expect(run.postCompactReminderInFlight).toBe(true); + expect(run.pendingPostCompactReminder).toBe(false); + // Verify a second write happened (the follow-up reminder) + expect(writeSpy).toHaveBeenCalledTimes(1); + + await svc.cancelProvisioning(runId); + }); + + it('reminder reads live config.json members instead of stale launch-time members', async () => { + const { svc, run, runId, writeSpy } = await setupRunningTeam('compact-test-15'); + + // Original launch had only alice + run.request.members = [{ name: 'alice', role: 'developer' }]; + + // Mock configReader.getConfig to return updated team with alice + bob + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + name: 'compact-test-15', + description: 'Test team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', agentType: 'teammate', role: 'developer' }, + { name: 'bob', agentType: 'teammate', role: 'tester' }, + ], + })), + }; + + run.pendingPostCompactReminder = true; + writeSpy.mockClear(); + await (svc as any).injectPostCompactReminder(run); + + const payload = String(writeSpy.mock.calls[0]?.[0] ?? ''); + const parsed = JSON.parse(payload) as { + type: string; + message?: { role: string; content: { type: string; text?: string }[] }; + }; + const text = parsed.message?.content?.[0]?.text ?? ''; + + // Should contain bob from live config, not just alice from launch-time + expect(text).toContain('bob'); + expect(text).toContain('alice'); + // Should NOT be in solo mode — check for the actual solo constraint block + expect(text).not.toContain('SOLO MODE: This team CURRENTLY has ZERO teammates'); + // Members section should include both + expect(text).toContain('- alice (developer)'); + expect(text).toContain('- bob (tester)'); + + await svc.cancelProvisioning(runId); + }); });