From 4cb1b6ef5f75a865372098e7d7a0d949d51a2b7a Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 16 May 2026 12:35:06 +0300 Subject: [PATCH 1/9] fix(member-work-sync): recover stale native in-progress tasks --- .../MemberWorkSyncNudgeActivationPolicy.ts | 137 ++++++- ...emberWorkSyncNudgeActivationPolicy.test.ts | 363 ++++++++++++++++++ .../main/createMemberWorkSyncFeature.test.ts | 187 +++++++++ 3 files changed, 686 insertions(+), 1 deletion(-) diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts index 49439b5c..24d5b21b 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts @@ -7,16 +7,24 @@ import { type MemberWorkSyncTargetedRecoveryReason, } from './MemberWorkSyncTargetedRecoveryPolicy'; -import type { MemberWorkSyncStatus, MemberWorkSyncTeamMetrics } from '../../contracts'; +import type { + MemberWorkSyncMetricEvent, + MemberWorkSyncStatus, + MemberWorkSyncTeamMetrics, +} from '../../contracts'; export type MemberWorkSyncNudgeActivationReason = | 'shadow_ready' | MemberWorkSyncTargetedRecoveryReason | 'review_pickup_required' + | 'native_stale_in_progress' | 'status_not_nudgeable' | 'blocking_metrics' | 'phase2_not_ready'; +const NATIVE_STALE_IN_PROGRESS_MIN_AGE_MS = 6 * 60_000; +const NATIVE_STALE_IN_PROGRESS_PROVIDERS = new Set(['anthropic', 'codex', 'gemini']); + export interface MemberWorkSyncNudgeActivationDecision { active: boolean; reason: MemberWorkSyncNudgeActivationReason; @@ -32,6 +40,129 @@ function hasBlockingMetrics(metrics: MemberWorkSyncTeamMetrics): boolean { return metrics.phase2Readiness.reasons.some((reason) => BLOCKING_PHASE2_REASONS.has(reason)); } +function normalizeMemberName(value: string): string { + return value.trim().toLowerCase(); +} + +function isLeadLikeMemberName(memberName: string): boolean { + const normalized = normalizeMemberName(memberName).replace(/[\s_]+/g, '-'); + return ( + normalized === 'lead' || + normalized === 'team-lead' || + normalized === 'teamlead' || + normalized === 'team-leader' + ); +} + +function parseTime(value: string | undefined): number | null { + if (!value) { + return null; + } + const time = Date.parse(value); + return Number.isFinite(time) ? time : null; +} + +function eventsForMember( + status: MemberWorkSyncStatus, + metrics: MemberWorkSyncTeamMetrics +): MemberWorkSyncMetricEvent[] { + const memberName = normalizeMemberName(status.memberName); + return metrics.recentEvents + .filter((event) => normalizeMemberName(event.memberName) === memberName) + .sort((left, right) => left.recordedAt.localeCompare(right.recordedAt)); +} + +function hasAcceptedReportForCurrentFingerprint( + status: MemberWorkSyncStatus, + metrics: MemberWorkSyncTeamMetrics +): boolean { + return eventsForMember(status, metrics).some( + (event) => + event.kind === 'report_accepted' && event.agendaFingerprint === status.agenda.fingerprint + ); +} + +function isDifferentFingerprintBoundary( + event: MemberWorkSyncMetricEvent, + currentFingerprint: string +): boolean { + if (event.agendaFingerprint !== currentFingerprint) { + return true; + } + return ( + event.kind === 'fingerprint_changed' && + event.previousFingerprint !== undefined && + event.previousFingerprint !== currentFingerprint + ); +} + +function getCurrentFingerprintStableSinceMs( + status: MemberWorkSyncStatus, + metrics: MemberWorkSyncTeamMetrics, + nowMs: number +): number | null { + const currentFingerprint = status.agenda.fingerprint; + const memberEvents = eventsForMember(status, metrics).filter((event) => { + const recordedAt = parseTime(event.recordedAt); + return recordedAt != null && recordedAt <= nowMs; + }); + let latestDifferentFingerprintMs = Number.NEGATIVE_INFINITY; + for (const event of memberEvents) { + const recordedAt = parseTime(event.recordedAt); + if (recordedAt != null && isDifferentFingerprintBoundary(event, currentFingerprint)) { + latestDifferentFingerprintMs = Math.max(latestDifferentFingerprintMs, recordedAt); + } + } + + const currentNeedsSyncEventTimes = memberEvents.flatMap((event) => { + const recordedAt = parseTime(event.recordedAt); + return event.kind === 'status_evaluated' && + event.state === 'needs_sync' && + event.agendaFingerprint === currentFingerprint && + recordedAt != null && + recordedAt >= latestDifferentFingerprintMs + ? [recordedAt] + : []; + }); + + return currentNeedsSyncEventTimes.length > 0 ? Math.min(...currentNeedsSyncEventTimes) : null; +} + +function isNativeStaleInProgressCandidate(input: { + status: MemberWorkSyncStatus; + metrics: MemberWorkSyncTeamMetrics; +}): boolean { + const { status, metrics } = input; + if ( + status.state !== 'needs_sync' || + status.shadow?.wouldNudge !== true || + !status.diagnostics.includes('no_current_report') || + !status.providerId || + !NATIVE_STALE_IN_PROGRESS_PROVIDERS.has(status.providerId) || + isLeadLikeMemberName(status.memberName) || + status.agenda.items.length !== 1 || + hasAcceptedReportForCurrentFingerprint(status, metrics) + ) { + return false; + } + + const [item] = status.agenda.items; + if ( + item.kind !== 'work' || + item.reason !== 'owned_in_progress_task' || + item.evidence.status !== 'in_progress' + ) { + return false; + } + + const nowMs = parseTime(metrics.generatedAt) ?? parseTime(status.evaluatedAt); + if (nowMs == null) { + return false; + } + const stableSinceMs = getCurrentFingerprintStableSinceMs(status, metrics, nowMs); + return stableSinceMs != null && nowMs - stableSinceMs >= NATIVE_STALE_IN_PROGRESS_MIN_AGE_MS; +} + function isReviewPickupRequiredCandidate(status: MemberWorkSyncStatus): boolean { return ( status.state === 'needs_sync' && @@ -61,6 +192,10 @@ export function decideMemberWorkSyncNudgeActivation(input: { return { active: true, reason: targetedRecovery.reason }; } + if (isNativeStaleInProgressCandidate(input)) { + return { active: true, reason: 'native_stale_in_progress' }; + } + if (hasBlockingMetrics(input.metrics)) { return { active: false, reason: 'blocking_metrics' }; } diff --git a/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts b/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts index 74883e6a..86e3cad2 100644 --- a/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts +++ b/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts @@ -86,6 +86,62 @@ function metrics(overrides: Partial = {}): MemberWork }; } +function nativeStaleInProgressStatus( + overrides: Partial = {} +): MemberWorkSyncStatus { + const base = status({ + providerId: 'codex', + diagnostics: ['no_current_report'], + agenda: { + ...status().agenda, + fingerprint: 'agenda:v1:native-stale', + items: [ + { + taskId: 'task-1', + displayId: '#1', + subject: 'Review landing', + kind: 'work', + assignee: 'alice', + priority: 'normal', + reason: 'owned_in_progress_task', + evidence: { + status: 'in_progress', + owner: 'alice', + }, + }, + ], + }, + }); + return { ...base, ...overrides }; +} + +function staleMetrics( + overrides: Partial = {} +): MemberWorkSyncTeamMetrics { + return metrics({ + generatedAt: '2026-05-06T00:06:00.000Z', + phase2Readiness: { + ...metrics().phase2Readiness, + state: 'blocked', + reasons: ['would_nudge_rate_high', 'fingerprint_churn_high'], + }, + recentEvents: [ + { + id: 'status-stale', + teamName: 'team-a', + memberName: 'alice', + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + recordedAt: '2026-05-06T00:00:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + ], + ...overrides, + }); +} + describe('MemberWorkSyncNudgeActivationPolicy', () => { it('activates OpenCode targeted nudges while shadow data is still collecting', () => { expect( @@ -348,6 +404,313 @@ describe('MemberWorkSyncNudgeActivationPolicy', () => { ).toEqual({ active: false, reason: 'blocking_metrics' }); }); + it('activates stale native single in-progress recovery despite blocking metrics', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus(), + metrics: staleMetrics(), + }) + ).toEqual({ active: true, reason: 'native_stale_in_progress' }); + }); + + it('does not activate stale native in-progress recovery before the quiet window elapses', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus(), + metrics: staleMetrics({ + generatedAt: '2026-05-06T00:05:59.000Z', + }), + }) + ).toEqual({ active: false, reason: 'blocking_metrics' }); + }); + + it('does not activate stale native in-progress recovery after an accepted report for the fingerprint', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus(), + metrics: staleMetrics({ + recentEvents: [ + ...staleMetrics().recentEvents, + { + id: 'report-accepted', + teamName: 'team-a', + memberName: 'alice', + kind: 'report_accepted', + state: 'still_working', + agendaFingerprint: 'agenda:v1:native-stale', + recordedAt: '2026-05-06T00:03:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + ], + }), + }) + ).toEqual({ active: false, reason: 'blocking_metrics' }); + }); + + it('does not activate stale native in-progress recovery when the accepted report state is still current', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus({ + state: 'still_working', + report: { + state: 'still_working', + agendaFingerprint: 'agenda:v1:native-stale', + memberName: 'alice', + teamName: 'team-a', + reportedAt: '2026-05-06T00:03:00.000Z', + accepted: true, + }, + }), + metrics: staleMetrics(), + }) + ).toEqual({ active: false, reason: 'status_not_nudgeable' }); + }); + + it('resets the stale native in-progress quiet window after a fingerprint change', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus(), + metrics: staleMetrics({ + generatedAt: '2026-05-06T00:08:59.000Z', + recentEvents: [ + { + id: 'old-same-fingerprint', + teamName: 'team-a', + memberName: 'alice', + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + recordedAt: '2026-05-06T00:00:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + { + id: 'fingerprint-returned', + teamName: 'team-a', + memberName: 'alice', + kind: 'fingerprint_changed', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + previousFingerprint: 'agenda:v1:other', + recordedAt: '2026-05-06T00:03:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + { + id: 'current-after-change', + teamName: 'team-a', + memberName: 'alice', + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + previousFingerprint: 'agenda:v1:other', + recordedAt: '2026-05-06T00:03:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + ], + }), + }) + ).toEqual({ active: false, reason: 'blocking_metrics' }); + }); + + it('activates stale native in-progress recovery after a returned fingerprint is stable long enough', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus(), + metrics: staleMetrics({ + generatedAt: '2026-05-06T00:09:00.000Z', + recentEvents: [ + { + id: 'old-same-fingerprint', + teamName: 'team-a', + memberName: 'alice', + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + recordedAt: '2026-05-06T00:00:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + { + id: 'fingerprint-returned', + teamName: 'team-a', + memberName: 'alice', + kind: 'fingerprint_changed', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + previousFingerprint: 'agenda:v1:other', + recordedAt: '2026-05-06T00:03:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + { + id: 'current-after-change', + teamName: 'team-a', + memberName: 'alice', + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + previousFingerprint: 'agenda:v1:other', + recordedAt: '2026-05-06T00:03:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + ], + }), + }) + ).toEqual({ active: true, reason: 'native_stale_in_progress' }); + }); + + it('does not activate stale native in-progress recovery from another member stale event', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus(), + metrics: staleMetrics({ + recentEvents: [ + { + id: 'other-member-status-stale', + teamName: 'team-a', + memberName: 'bob', + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + recordedAt: '2026-05-06T00:00:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + ], + }), + }) + ).toEqual({ active: false, reason: 'blocking_metrics' }); + }); + + it('does not use native stale recovery for OpenCode or lead members', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus({ providerId: 'opencode' }), + metrics: staleMetrics(), + }) + ).toEqual({ active: true, reason: 'opencode_targeted_shadow_collecting' }); + + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus({ memberName: 'team-lead' }), + metrics: staleMetrics(), + }) + ).toEqual({ active: true, reason: 'lead_targeted_shadow_collecting' }); + }); + + it('does not activate stale native in-progress recovery for multiple or non-in-progress work items', () => { + const baseItem = nativeStaleInProgressStatus().agenda.items[0]!; + + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus({ + agenda: { + ...nativeStaleInProgressStatus().agenda, + items: [ + baseItem, + { + ...baseItem, + taskId: 'task-2', + }, + ], + }, + }), + metrics: staleMetrics(), + }) + ).toEqual({ active: false, reason: 'blocking_metrics' }); + + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus({ + agenda: { + ...nativeStaleInProgressStatus().agenda, + items: [ + { + ...baseItem, + reason: 'owned_pending_task', + evidence: { + status: 'pending', + owner: 'alice', + }, + }, + ], + }, + }), + metrics: staleMetrics(), + }) + ).toEqual({ active: false, reason: 'blocking_metrics' }); + }); + + it('does not activate stale native in-progress recovery for needsFix, review, blocked dependency, or clarification agenda items', () => { + const baseItem = nativeStaleInProgressStatus().agenda.items[0]!; + const cases = [ + { + ...baseItem, + evidence: { + status: 'needsFix', + owner: 'alice', + }, + }, + { + ...baseItem, + kind: 'review' as const, + priority: 'review_requested' as const, + reason: 'current_cycle_review_assigned', + evidence: { + status: 'completed', + owner: 'bob', + reviewer: 'alice', + reviewState: 'review', + reviewCycleId: 'evt-review-request', + reviewRequestEventId: 'evt-review-request', + reviewObligation: 'review_pickup_required' as const, + canBypassPhase2: true, + historyEventIds: ['evt-review-request'], + }, + }, + { + ...baseItem, + kind: 'blocked_dependency' as const, + priority: 'blocked' as const, + reason: 'blocked_by_incomplete_task', + evidence: { + status: 'in_progress', + owner: 'alice', + blockerTaskIds: ['task-blocker'], + }, + }, + { + ...baseItem, + kind: 'clarification' as const, + priority: 'needs_clarification' as const, + reason: 'lead_clarification_required', + evidence: { + status: 'in_progress', + owner: 'alice', + needsClarification: 'lead' as const, + }, + }, + ]; + + for (const item of cases) { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus({ + agenda: { + ...nativeStaleInProgressStatus().agenda, + items: [item], + }, + }), + metrics: staleMetrics(), + }) + ).toEqual({ active: false, reason: 'blocking_metrics' }); + } + }); + it('keeps existing shadow_ready behavior for all providers', () => { expect( decideMemberWorkSyncNudgeActivation({ diff --git a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts index 7ce261db..c5ca1465 100644 --- a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts +++ b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts @@ -179,6 +179,69 @@ async function seedBlockingShadowCollectingMetrics(input: { ); } +async function seedNativeStaleInProgressBlockingMetrics(input: { + teamsBasePath: string; + teamName: string; + memberName: string; + agendaFingerprint: string; +}): Promise { + const nowMs = Date.now(); + const staleObservedAt = new Date(nowMs - 6 * 60_000 - 1_000).toISOString(); + const metricsPath = path.join( + input.teamsBasePath, + input.teamName, + '.member-work-sync', + 'indexes', + 'metrics.json' + ); + await fs.promises.mkdir(path.dirname(metricsPath), { recursive: true }); + await fs.promises.writeFile( + metricsPath, + `${JSON.stringify( + { + schemaVersion: 2, + members: { + [input.memberName]: { + memberName: input.memberName, + state: 'needs_sync', + agendaFingerprint: input.agendaFingerprint, + actionableCount: 1, + evaluatedAt: staleObservedAt, + providerId: 'codex', + }, + }, + recentEvents: [ + { + id: 'native-stale-status', + teamName: input.teamName, + memberName: input.memberName, + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: input.agendaFingerprint, + recordedAt: staleObservedAt, + actionableCount: 1, + providerId: 'codex', + }, + ...Array.from({ length: 12 }, (_, index) => ({ + id: `native-stale-would-nudge-${index}`, + teamName: input.teamName, + memberName: input.memberName, + kind: 'would_nudge', + state: 'needs_sync', + agendaFingerprint: input.agendaFingerprint, + recordedAt: new Date(nowMs - 5 * 60_000 + index * 5_000).toISOString(), + actionableCount: 1, + providerId: 'codex', + })), + ], + }, + null, + 2 + )}\n`, + 'utf8' + ); +} + async function waitForAssertion(assertion: () => Promise | void): Promise { const deadline = Date.now() + 5_000; let lastError: unknown; @@ -1067,6 +1130,130 @@ describe('createMemberWorkSyncFeature composition', () => { } }); + it('delivers native stale in-progress recovery nudges despite noisy global metrics', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-native-stale-in-progress'; + const memberName = 'alice'; + const nudgeDeliveryWake = { + schedule: vi.fn(async () => undefined), + }; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'codex' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Review landing', + status: 'in_progress', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + nudgeDeliveryWake, + queueQuietWindowMs: 1, + }); + + try { + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + let agendaFingerprint = ''; + await waitForAssertion(async () => { + const status = await feature.getStatus({ teamName, memberName }); + expect(status).toMatchObject({ + state: 'needs_sync', + providerId: 'codex', + diagnostics: expect.arrayContaining(['no_current_report']), + agenda: { + items: [ + expect.objectContaining({ + reason: 'owned_in_progress_task', + evidence: expect.objectContaining({ status: 'in_progress' }), + }), + ], + }, + }); + agendaFingerprint = status.agenda.fingerprint; + }); + expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]); + expect(nudgeDeliveryWake.schedule).not.toHaveBeenCalled(); + + await seedNativeStaleInProgressBlockingMetrics({ + teamsBasePath, + teamName, + memberName, + agendaFingerprint, + }); + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(1); + expect(nudges[0]?.text).toContain('Work sync check'); + expect(nudges[0]?.text).toContain('11111111'); + expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1); + expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({ + teamName, + memberName, + messageId: nudges[0]?.messageId, + providerId: 'codex', + reason: 'member_work_sync_nudge_inserted', + delayMs: 500, + }); + await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({ + phase2Readiness: { + reasons: expect.arrayContaining(['would_nudge_rate_high']), + }, + }); + expect( + Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) + ).toEqual([ + expect.objectContaining({ + status: 'delivered', + deliveredMessageId: nudges[0]?.messageId, + }), + ]); + }); + + const journal = await fs.promises.readFile( + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'journal.jsonl' + ), + 'utf8' + ); + expect(journal).toContain('"event":"nudge_delivered"'); + expect(journal).toContain('"reason":"created"'); + } finally { + await feature.dispose(); + } + }); + it('delivers targeted OpenCode nudges even when global phase2 metrics are noisy', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); From 2beb4dae963badc1c85be0b5904f175f90ee27b1 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 16 May 2026 12:37:17 +0300 Subject: [PATCH 2/9] feat: improve desktop workflows --- README.md | 2 +- package.json | 1 + pnpm-lock.yaml | 31 ++ .../renderer/hooks/useGraphMessagesPanel.tsx | 86 +++++ .../renderer/ui/TeamGraphOverlay.tsx | 22 +- .../agent-graph/renderer/ui/TeamGraphTab.tsx | 21 +- src/features/recent-projects/contracts/dto.ts | 3 + .../domain/models/RecentProjectAggregate.ts | 2 + .../domain/models/RecentProjectCandidate.ts | 2 + .../models/RecentProjectFilesystemState.ts | 1 + .../policies/mergeRecentProjectCandidates.ts | 9 +- .../DashboardRecentProjectsPresenter.ts | 1 + .../ClaudeRecentProjectsSourceAdapter.ts | 1 + .../CodexRecentProjectsSourceAdapter.ts | 11 +- ...xSessionFileRecentProjectsSourceAdapter.ts | 7 +- .../resolveProjectFilesystemState.ts | 21 ++ .../adapters/RecentProjectsSectionAdapter.ts | 2 + .../renderer/hooks/useOpenRecentProject.ts | 5 + .../renderer/ui/RecentProjectCard.tsx | 44 ++- src/main/index.ts | 31 +- src/main/services/discovery/ProjectScanner.ts | 25 ++ .../infrastructure/CliInstallerService.ts | 2 +- .../runtime/ClaudeMultimodelBridgeService.ts | 2 +- src/main/types/domain.ts | 6 + .../components/dashboard/CliStatusBanner.tsx | 2 +- .../components/layout/TeamTabSectionNav.tsx | 2 +- .../settings/sections/CliStatusSection.tsx | 2 +- .../components/team/TeamDetailView.tsx | 17 +- .../team/dialogs/CreateTeamDialog.tsx | 17 + .../team/dialogs/LaunchTeamDialog.tsx | 24 ++ .../team/dialogs/ProjectPathSelector.tsx | 62 ++- .../team/dialogs/projectPathOptions.ts | 24 +- .../team/dialogs/projectPathProjects.ts | 19 +- .../team/messages/MessageComposer.tsx | 55 +-- .../team/messages/MessagesPanel.tsx | 353 +++++++++++------- src/renderer/components/ui/combobox.tsx | 11 +- src/renderer/components/ui/dropdown-menu.tsx | 65 ++++ .../store/slices/cliInstallerSlice.ts | 4 +- src/renderer/types/teamMessagesPanelMode.ts | 2 +- .../mergeRecentProjectCandidates.test.ts | 36 ++ ...ionFileRecentProjectsSourceAdapter.test.ts | 36 ++ .../RecentProjectsSectionAdapter.test.ts | 5 +- .../discovery/ProjectScanner.cwdSplit.test.ts | 22 ++ .../CliInstallerService.test.ts | 2 +- .../ClaudeMultimodelBridgeService.test.ts | 2 +- .../cli/CliStatusVisibility.test.ts | 8 +- .../extensions/skills/SkillsPanel.test.ts | 2 +- .../team/dialogs/LaunchTeamDialog.test.ts | 6 + .../team/dialogs/projectPathOptions.test.ts | 23 ++ 49 files changed, 900 insertions(+), 239 deletions(-) create mode 100644 src/features/agent-graph/renderer/hooks/useGraphMessagesPanel.tsx create mode 100644 src/features/recent-projects/core/domain/models/RecentProjectFilesystemState.ts create mode 100644 src/features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState.ts create mode 100644 src/renderer/components/ui/dropdown-menu.tsx diff --git a/README.md b/README.md index c32e26f8..678494d5 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@

- Free desktop app for AI agent teams. Auto-detects Claude/Codex/OpenCode (75+ LLM providers). Use the provider access you already have - subscriptions or API keys. Not just coding agents. + Free desktop app for AI agent teams. Auto-detects Claude/Codex/OpenCode (200+ models). Use the provider access you already have - subscriptions or API keys. Not just coding agents.

image diff --git a/package.json b/package.json index 69a44a81..4dcb49dc 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,7 @@ "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bb07233..d8b7e807 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,9 @@ importers: '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dropdown-menu': + specifier: 2.1.16 + version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-hover-card': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -3191,6 +3194,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-guards@1.1.3': resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} peerDependencies: @@ -13999,6 +14015,21 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)': dependencies: react: 19.2.4 diff --git a/src/features/agent-graph/renderer/hooks/useGraphMessagesPanel.tsx b/src/features/agent-graph/renderer/hooks/useGraphMessagesPanel.tsx new file mode 100644 index 00000000..70ee7043 --- /dev/null +++ b/src/features/agent-graph/renderer/hooks/useGraphMessagesPanel.tsx @@ -0,0 +1,86 @@ +import { type ReactElement, useCallback, useEffect, useMemo, useState } from 'react'; + +import { MessagesPanel } from '@renderer/components/team/messages/MessagesPanel'; +import { + getTeamPendingRepliesState, + setTeamPendingRepliesState, +} from '@renderer/components/team/sidebar/teamSidebarUiState'; +import { useStore } from '@renderer/store'; +import { + selectResolvedMembersForTeamName, + selectTeamDataForName, +} from '@renderer/store/slices/teamSlice'; +import { useShallow } from 'zustand/react/shallow'; + +interface UseGraphMessagesPanelInput { + teamName: string; + enabled?: boolean; + mountPoint?: Element | null; + onOpenMemberProfile: (memberName: string) => void; + onOpenTaskDetail: (taskId: string) => void; +} + +export function useGraphMessagesPanel({ + teamName, + enabled = true, + mountPoint, + onOpenMemberProfile, + onOpenTaskDetail, +}: UseGraphMessagesPanelInput): ReactElement { + const [pendingRepliesByMember, setPendingRepliesByMember] = useState(() => + getTeamPendingRepliesState(teamName) + ); + const { messagesPanelMode, setMessagesPanelMode, members, tasks, isTeamAlive } = useStore( + useShallow((state) => { + const teamData = selectTeamDataForName(state, teamName); + return { + messagesPanelMode: state.messagesPanelMode, + setMessagesPanelMode: state.setMessagesPanelMode, + members: selectResolvedMembersForTeamName(state, teamName), + tasks: teamData?.tasks ?? [], + isTeamAlive: teamData?.isAlive, + }; + }) + ); + const activeMembers = useMemo(() => members.filter((member) => !member.removedAt), [members]); + + useEffect(() => { + setPendingRepliesByMember(getTeamPendingRepliesState(teamName)); + }, [teamName]); + + useEffect(() => { + setTeamPendingRepliesState(teamName, pendingRepliesByMember); + }, [pendingRepliesByMember, teamName]); + + const handlePendingReplyChange = useCallback( + (updater: (prev: Record) => Record) => { + setPendingRepliesByMember(updater); + }, + [] + ); + + if ( + !enabled || + (messagesPanelMode !== 'floating-composer' && messagesPanelMode !== 'bottom-sheet') + ) { + return <>; + } + + return ( + onOpenMemberProfile(member.name)} + onTaskClick={(task) => onOpenTaskDetail(task.id)} + onTaskIdClick={onOpenTaskDetail} + /> + ); +} diff --git a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index aebbfd07..40693276 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -3,12 +3,13 @@ * Follows the exact ProjectEditorOverlay pattern (lazy-loaded, fixed z-50). */ -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { GraphView } from '@claude-teams/agent-graph'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog'; +import { useGraphMessagesPanel } from '../hooks/useGraphMessagesPanel'; import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility'; import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter'; import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions'; @@ -36,6 +37,7 @@ export interface TeamGraphOverlayProps { onPinAsTab?: () => void; sidebarVisible?: boolean; onToggleSidebar?: () => void; + messagesPanelEnabled?: boolean; onSendMessage?: (memberName: string) => void; onOpenTaskDetail?: (taskId: string) => void; onOpenMemberProfile?: ( @@ -53,6 +55,7 @@ export const TeamGraphOverlay = ({ onPinAsTab, sidebarVisible, onToggleSidebar, + messagesPanelEnabled = true, onSendMessage, onOpenTaskDetail, onOpenMemberProfile, @@ -67,8 +70,18 @@ export const TeamGraphOverlay = ({ const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility(); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); + const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState( + null + ); const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible; const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible; + const graphMessagesPanel = useGraphMessagesPanel({ + teamName, + enabled: messagesPanelEnabled, + mountPoint: messagesPanelMountPoint, + onOpenMemberProfile: (memberName) => onOpenMemberProfile?.(memberName), + onOpenTaskDetail: (taskId) => onOpenTaskDetail?.(taskId), + }); // Task action dispatchers (same pattern as TeamGraphTab) const dispatchTaskAction = useCallback( @@ -242,6 +255,13 @@ export const TeamGraphOverlay = ({ /> )} /> + {messagesPanelEnabled ? ( +
+ ) : null} + {graphMessagesPanel} {createTaskDialog}
); diff --git a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx index a0ab67f2..29356208 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx @@ -9,6 +9,7 @@ import { GraphView } from '@claude-teams/agent-graph'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog'; +import { useGraphMessagesPanel } from '../hooks/useGraphMessagesPanel'; import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility'; import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter'; import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions'; @@ -54,6 +55,9 @@ export const TeamGraphTab = ({ const { openTeamPage, commitOwnerSlotDrop, commitOwnerGridOrderDrop, setLayoutMode } = useTeamGraphSurfaceActions(teamName); const [fullscreen, setFullscreen] = useState(false); + const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState( + null + ); const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility(); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); @@ -129,9 +133,16 @@ export const TeamGraphTab = ({ [dispatchOpenProfile] ), }; + const graphMessagesPanel = useGraphMessagesPanel({ + teamName, + enabled: isActive && isPaneFocused && !fullscreen, + mountPoint: messagesPanelMountPoint, + onOpenMemberProfile: dispatchOpenProfile, + onOpenTaskDetail: dispatchOpenTask, + }); return ( -
+
{sidebarVisible ? (
+ {isActive && isPaneFocused && !fullscreen ? ( +
+ ) : null} + {graphMessagesPanel} {createTaskDialog} {fullscreen && ( @@ -273,6 +291,7 @@ export const TeamGraphTab = ({ onSendMessage={dispatchSendMessage} onOpenTaskDetail={dispatchOpenTask} onOpenMemberProfile={dispatchOpenProfile} + messagesPanelEnabled={isActive && isPaneFocused} /> )} diff --git a/src/features/recent-projects/contracts/dto.ts b/src/features/recent-projects/contracts/dto.ts index bdb4eda0..1fa6e5c6 100644 --- a/src/features/recent-projects/contracts/dto.ts +++ b/src/features/recent-projects/contracts/dto.ts @@ -2,6 +2,8 @@ export type DashboardProviderId = 'anthropic' | 'codex' | 'gemini'; export type DashboardRecentProjectSource = 'claude' | 'codex' | 'mixed'; +export type DashboardRecentProjectFilesystemState = 'available' | 'deleted'; + export type DashboardRecentProjectOpenTarget = | { type: 'existing-worktree'; repositoryId: string; worktreeId: string } | { type: 'synthetic-path'; path: string }; @@ -16,6 +18,7 @@ export interface DashboardRecentProject { source: DashboardRecentProjectSource; openTarget: DashboardRecentProjectOpenTarget; primaryBranch?: string; + filesystemState?: DashboardRecentProjectFilesystemState; } export interface DashboardRecentProjectsPayload { diff --git a/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts b/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts index 9c098a65..26402f5d 100644 --- a/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts +++ b/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts @@ -1,4 +1,5 @@ import type { ProviderId } from './ProviderId'; +import type { RecentProjectFilesystemState } from './RecentProjectFilesystemState'; import type { RecentProjectOpenTarget } from './RecentProjectOpenTarget'; export interface RecentProjectAggregate { @@ -11,4 +12,5 @@ export interface RecentProjectAggregate { source: 'claude' | 'codex' | 'mixed'; openTarget: RecentProjectOpenTarget; branchName?: string; + filesystemState: RecentProjectFilesystemState; } diff --git a/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts b/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts index 2abc5315..17c389d2 100644 --- a/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts +++ b/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts @@ -1,4 +1,5 @@ import type { ProviderId } from './ProviderId'; +import type { RecentProjectFilesystemState } from './RecentProjectFilesystemState'; import type { RecentProjectOpenTarget } from './RecentProjectOpenTarget'; export interface RecentProjectCandidate { @@ -11,4 +12,5 @@ export interface RecentProjectCandidate { sourceKind: 'claude' | 'codex'; openTarget: RecentProjectOpenTarget; branchName?: string; + filesystemState?: RecentProjectFilesystemState; } diff --git a/src/features/recent-projects/core/domain/models/RecentProjectFilesystemState.ts b/src/features/recent-projects/core/domain/models/RecentProjectFilesystemState.ts new file mode 100644 index 00000000..22a3029a --- /dev/null +++ b/src/features/recent-projects/core/domain/models/RecentProjectFilesystemState.ts @@ -0,0 +1 @@ +export type RecentProjectFilesystemState = 'available' | 'deleted'; diff --git a/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts b/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts index ce3be598..41efa95b 100644 --- a/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts +++ b/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts @@ -25,10 +25,14 @@ function uniqueProviders(providerIds: readonly ProviderId[]): ProviderId[] { function selectPreferredCandidate( candidates: readonly RecentProjectCandidate[] ): RecentProjectCandidate { - const existingWorktreeCandidates = candidates.filter( + const availableCandidates = candidates.filter( + (candidate) => candidate.filesystemState !== 'deleted' + ); + const candidatePool = availableCandidates.length > 0 ? availableCandidates : candidates; + const existingWorktreeCandidates = candidatePool.filter( (candidate) => candidate.openTarget.type === 'existing-worktree' ); - const pool = existingWorktreeCandidates.length > 0 ? existingWorktreeCandidates : candidates; + const pool = existingWorktreeCandidates.length > 0 ? existingWorktreeCandidates : candidatePool; return [...pool].sort((left, right) => { if (right.lastActivityAt !== left.lastActivityAt) { @@ -81,6 +85,7 @@ export function mergeRecentProjectCandidates( source: sourceKinds.size > 1 ? 'mixed' : sourceKinds.has('codex') ? 'codex' : 'claude', openTarget: preferred.openTarget, branchName: mergeBranchName(group), + filesystemState: preferred.filesystemState ?? 'available', }; }); diff --git a/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts b/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts index c9a3e5fb..887ae7c8 100644 --- a/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts +++ b/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts @@ -20,6 +20,7 @@ export class DashboardRecentProjectsPresenter implements ListDashboardRecentProj source: aggregate.source, openTarget: aggregate.openTarget, primaryBranch: aggregate.branchName, + filesystemState: aggregate.filesystemState, }) ), }; diff --git a/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts index d1d55a94..faefffb6 100644 --- a/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts @@ -44,6 +44,7 @@ function toCandidate(repo: RepositoryGroup): RecentProjectCandidate | null { worktreeId: preferredWorktree.id, }, branchName: preferredWorktree.gitBranch, + filesystemState: preferredWorktree.filesystemState ?? 'available', }; } diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts index 75558354..8e13c8f2 100644 --- a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts @@ -1,4 +1,5 @@ import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath'; +import { resolveProjectFilesystemState } from '@features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState'; import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath'; import path from 'path'; @@ -129,7 +130,9 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor ); const candidates = ( - await Promise.all(interactiveThreads.map((thread) => this.#toCandidate(thread))) + await Promise.all( + interactiveThreads.map((thread) => this.#toCandidate(thread, activeContext.fsProvider)) + ) ).filter((candidate): candidate is RecentProjectCandidate => candidate !== null); if (!degraded) { @@ -299,7 +302,10 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor } } - async #toCandidate(thread: CodexThreadSummary): Promise { + async #toCandidate( + thread: CodexThreadSummary, + fsProvider?: ServiceContext['fsProvider'] + ): Promise { const cwd = thread.cwd?.trim(); if (!cwd || isEphemeralProjectPath(cwd)) { return null; @@ -321,6 +327,7 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor path: cwd, }, branchName: thread.gitInfo?.branch ?? undefined, + filesystemState: await resolveProjectFilesystemState(cwd, fsProvider), }; } } diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts index cec73762..a6140760 100644 --- a/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath'; +import { resolveProjectFilesystemState } from '@features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState'; import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath'; import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort'; @@ -225,7 +226,7 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec try { const snapshots = await this.#listRecentSessionSnapshots(); const candidates = await Promise.all( - snapshots.map((snapshot) => this.#toCandidate(snapshot)) + snapshots.map((snapshot) => this.#toCandidate(snapshot, activeContext.fsProvider)) ); const validCandidates = candidates.filter( @@ -303,7 +304,8 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec } async #toCandidate( - snapshot: CodexSessionProjectSnapshot + snapshot: CodexSessionProjectSnapshot, + fsProvider?: ServiceContext['fsProvider'] ): Promise { const identity = await this.deps.identityResolver.resolve(snapshot.cwd); const displayName = identity?.name ?? path.basename(snapshot.cwd) ?? snapshot.cwd; @@ -321,6 +323,7 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec path: snapshot.cwd, }, branchName: snapshot.branchName, + filesystemState: await resolveProjectFilesystemState(snapshot.cwd, fsProvider), }; } } diff --git a/src/features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState.ts b/src/features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState.ts new file mode 100644 index 00000000..5f1a1d7f --- /dev/null +++ b/src/features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState.ts @@ -0,0 +1,21 @@ +import type { RecentProjectFilesystemState } from '../../../core/domain/models/RecentProjectFilesystemState'; +import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider'; + +export async function resolveProjectFilesystemState( + projectPath: string, + fsProvider?: Pick +): Promise { + if (!projectPath.trim()) { + return 'deleted'; + } + + if (!fsProvider) { + return 'available'; + } + + try { + return (await fsProvider.exists(projectPath)) ? 'available' : 'deleted'; + } catch { + return 'deleted'; + } +} diff --git a/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts b/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts index 58b0cd79..7568b4aa 100644 --- a/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts +++ b/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts @@ -15,6 +15,7 @@ export interface RecentProjectCardModel { lastActivityLabel: string; providerIds: DashboardRecentProject['providerIds']; primaryBranch?: string; + filesystemState?: DashboardRecentProject['filesystemState']; taskCounts?: TaskStatusCounts; tasksLoading: boolean; activeTeams?: TeamSummary[]; @@ -121,6 +122,7 @@ export function adaptRecentProjectsSection({ }), providerIds: sortDashboardProviderIds(project.providerIds), primaryBranch: project.primaryBranch, + filesystemState: project.filesystemState, taskCounts: sumTaskCounts(project, taskCountsByProject), tasksLoading, activeTeams: collectActiveTeams(project, activeTeamsByProject), diff --git a/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts b/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts index fb7fd848..f01387f8 100644 --- a/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts +++ b/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts @@ -106,6 +106,11 @@ export function useOpenRecentProject(): { const openRecentProject = useCallback( async (project: DashboardRecentProject): Promise => { + if (project.filesystemState === 'deleted') { + logger.warn('Skipped deleted recent project path', { path: project.primaryPath }); + return; + } + try { await openTarget(project.openTarget, project.associatedPaths); recordRecentProjectOpenPaths([project.primaryPath, ...project.associatedPaths]); diff --git a/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx b/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx index ad187f70..cdc03227 100644 --- a/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx +++ b/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx @@ -3,8 +3,9 @@ import { useMemo } from 'react'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { ActivePulseIndicator } from '@renderer/components/ui/ActivePulseIndicator'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { cn } from '@renderer/lib/utils'; import { projectColor } from '@renderer/utils/projectColor'; -import { FolderGit2, FolderOpen, GitBranch, Terminal } from 'lucide-react'; +import { FolderGit2, FolderOpen, FolderX, GitBranch, Terminal } from 'lucide-react'; import type { RecentProjectCardModel } from '../adapters/RecentProjectsSectionAdapter'; @@ -20,11 +21,17 @@ export const RecentProjectCard = ({ onOpenPath, }: Readonly): React.JSX.Element => { const color = useMemo(() => projectColor(card.name), [card.name]); + const isDeleted = card.filesystemState === 'deleted'; + const FolderIcon = isDeleted ? FolderX : FolderGit2; return (
@@ -3650,6 +3659,12 @@ export const TeamDetailView = memo(function TeamDetailView({ .getState() .openTab({ type: 'graph', label: `${data.config.name} Graph`, teamName }); }} + messagesPanelEnabled={ + (messagesPanelMode === 'floating-composer' || + messagesPanelMode === 'bottom-sheet') && + isThisTabActive && + isPaneFocused + } onSendMessage={(memberName) => { setSendDialogRecipient(memberName); setSendDialogDefaultText(undefined); diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 3a22a60d..917c1ed9 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -671,6 +671,23 @@ export const CreateTeamDialog = ({ ); const lastPrepareRequestSignatureRef = useRef(null); + useEffect(() => { + const generation = ++prepareUnmountGenerationRef.current; + return () => { + // React StrictMode replays effect cleanup/setup in development; defer + // invalidation so the replay does not cancel the live prepare request. + queueMicrotask(() => { + if (!isCurrentPrepareGeneration(prepareUnmountGenerationRef, generation)) { + return; + } + cancelScheduledIdle(prepareIdleHandleRef.current); + prepareIdleHandleRef.current = null; + prepareRequestSeqRef.current += 1; + lastPrepareRequestSignatureRef.current = null; + }); + }; + }, []); + useEffect(() => { runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider; }, [runtimeBackendSummaryByProvider]); diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 838a497e..d16f3d91 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -79,6 +79,7 @@ import { CheckCircle2, ChevronDown, ChevronRight, + ExternalLink, Info, Loader2, X, @@ -230,6 +231,8 @@ export type LaunchTeamDialogProps = | LaunchDialogScheduleMode; const APP_TEAM_RUNTIME_DISALLOWED_TOOLS = 'TeamDelete,TodoWrite,TaskCreate,TaskUpdate'; +const ANTHROPIC_AGENT_SDK_CREDIT_ARTICLE_URL = + 'https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan'; // ============================================================================= // Helpers @@ -2730,6 +2733,27 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen This prompt will be passed to claude -p for one-shot execution

+ {selectedProviderId === 'anthropic' ? ( +
+ +

+ Starting June 15, 2026, Anthropic bills claude -p and Agent SDK + usage from the monthly Agent SDK credit, separate from interactive Claude Code + limits. The credit resets each billing cycle and unused credit does not roll + over.{' '} + + Read Anthropic article + + + . +

+
+ ) : null}
diff --git a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx index bb49c9ca..49d8af7c 100644 --- a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx +++ b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx @@ -7,7 +7,7 @@ import { Combobox } from '@renderer/components/ui/combobox'; import { Input } from '@renderer/components/ui/input'; import { Label } from '@renderer/components/ui/label'; import { cn } from '@renderer/lib/utils'; -import { Check, FolderOpen } from 'lucide-react'; +import { Check, FolderOpen, FolderX } from 'lucide-react'; import { buildProjectPathOptions, @@ -58,6 +58,10 @@ function getOptionSource(option: ComboboxOption): DashboardRecentProjectSource | return (option.meta as ProjectPathOptionMeta | undefined)?.discoverySource; } +function isDeletedOption(option: ComboboxOption): boolean { + return (option.meta as ProjectPathOptionMeta | undefined)?.filesystemState === 'deleted'; +} + function getSourceLabel(source: DashboardRecentProjectSource): string { switch (source) { case 'claude': @@ -97,6 +101,16 @@ const ProjectSourceBadge = ({ ); }; +const ProjectDeletedBadge = (): React.JSX.Element => ( + + + Deleted + +); + export type CwdMode = 'project' | 'custom'; interface ProjectPathSelectorProps { @@ -178,28 +192,38 @@ export const ProjectPathSelector = ({ renderTriggerLabel={(option) => ( + {isDeletedOption(option) ? : null} {option.label} )} - renderOption={(option, isSelected, query) => ( - <> - - -
-

- {renderHighlightedText(option.label, query)} -

-

- {renderHighlightedText(option.description ?? '', query)} -

+ renderOption={(option, isSelected, query) => { + const isDeleted = isDeletedOption(option); + return ( +
+ + + {isDeleted ? : null} +
+

+ {renderHighlightedText(option.label, query)} +

+

+ {renderHighlightedText(option.description ?? '', query)} +

+
- - )} + ); + }} />
diff --git a/src/renderer/components/team/dialogs/projectPathOptions.ts b/src/renderer/components/team/dialogs/projectPathOptions.ts index 8dd5af03..6cdae4b0 100644 --- a/src/renderer/components/team/dialogs/projectPathOptions.ts +++ b/src/renderer/components/team/dialogs/projectPathOptions.ts @@ -1,16 +1,21 @@ import { normalizePath } from '@renderer/utils/pathNormalize'; import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath'; -import type { DashboardRecentProjectSource } from '@features/recent-projects/contracts'; +import type { + DashboardRecentProjectFilesystemState, + DashboardRecentProjectSource, +} from '@features/recent-projects/contracts'; import type { ComboboxOption } from '@renderer/components/ui/combobox'; import type { Project } from '@shared/types'; export interface ProjectPathProject extends Project { discoverySource?: DashboardRecentProjectSource; + filesystemState?: DashboardRecentProjectFilesystemState; } export interface ProjectPathOptionMeta { discoverySource?: DashboardRecentProjectSource; + filesystemState?: DashboardRecentProjectFilesystemState; } function toProjectOption(project: ProjectPathProject): ComboboxOption { @@ -20,10 +25,19 @@ function toProjectOption(project: ProjectPathProject): ComboboxOption { description: project.path, }; - if (project.discoverySource !== undefined) { - option.meta = { - discoverySource: project.discoverySource, - } satisfies ProjectPathOptionMeta; + if (project.filesystemState === 'deleted') { + option.disabled = true; + } + + if (project.discoverySource !== undefined || project.filesystemState !== undefined) { + const meta: ProjectPathOptionMeta = {}; + if (project.discoverySource !== undefined) { + meta.discoverySource = project.discoverySource; + } + if (project.filesystemState !== undefined) { + meta.filesystemState = project.filesystemState; + } + option.meta = meta; } return option; diff --git a/src/renderer/components/team/dialogs/projectPathProjects.ts b/src/renderer/components/team/dialogs/projectPathProjects.ts index 60dcbbd6..b180a5bc 100644 --- a/src/renderer/components/team/dialogs/projectPathProjects.ts +++ b/src/renderer/components/team/dialogs/projectPathProjects.ts @@ -22,6 +22,14 @@ function mergeDiscoverySource( return 'mixed'; } +function mergeFilesystemState( + current: ProjectPathProject['filesystemState'], + next: ProjectPathProject['filesystemState'] +): ProjectPathProject['filesystemState'] { + if (current === 'available' || next === 'available') return 'available'; + return current ?? next; +} + function getPathName(projectPath: string): string { return projectPath.split(/[/\\]/).filter(Boolean).pop() ?? projectPath; } @@ -47,6 +55,10 @@ function upsertProject( existing.discoverySource, project.discoverySource ); + existing.filesystemState = mergeFilesystemState( + existing.filesystemState, + project.filesystemState + ); if (!existing.mostRecentSession && project.mostRecentSession) { existing.mostRecentSession = project.mostRecentSession; } @@ -58,6 +70,7 @@ function recentProjectToProject(project: { primaryPath: string; mostRecentActivity: number; source: DashboardRecentProjectSource; + filesystemState?: ProjectPathProject['filesystemState']; }): ProjectPathProject { return { id: `recent:${project.id}`, @@ -68,10 +81,13 @@ function recentProjectToProject(project: { createdAt: project.mostRecentActivity, mostRecentSession: project.mostRecentActivity, discoverySource: project.source, + filesystemState: project.filesystemState, }; } -function repositoryWorktreeToProject(worktree: RepositoryGroup['worktrees'][number]): Project { +function repositoryWorktreeToProject( + worktree: RepositoryGroup['worktrees'][number] +): ProjectPathProject { return { id: worktree.id, path: worktree.path, @@ -79,6 +95,7 @@ function repositoryWorktreeToProject(worktree: RepositoryGroup['worktrees'][numb sessions: [], totalSessions: 0, createdAt: worktree.createdAt ?? Date.now(), + filesystemState: worktree.filesystemState, }; } diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 58c647f8..c3f66dc6 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -65,6 +65,7 @@ interface MessageComposerProps { sendWarning?: string | null; sendDebugDetails?: OpenCodeRuntimeDeliveryDebugDetails | null; lastResult?: SendMessageResult | null; + cornerActionPrefix?: React.ReactNode; /** Ref to the underlying textarea element for external focus management. */ textareaRef?: React.Ref; onSend: ( @@ -112,6 +113,7 @@ export const MessageComposer = ({ sendWarning, sendDebugDetails, lastResult, + cornerActionPrefix, textareaRef: externalTextareaRef, onSend, onCrossTeamSend, @@ -143,6 +145,7 @@ export const MessageComposer = ({ const [recipientOpen, setRecipientOpen] = useState(false); const [recipientSearch, setRecipientSearch] = useState(''); const recipientSearchRef = useRef(null); + const [isTextareaFocused, setIsTextareaFocused] = useState(false); const [isDragOver, setIsDragOver] = useState(false); const dragCounterRef = useRef(0); const fileInputRef = useRef(null); @@ -642,6 +645,8 @@ export const MessageComposer = ({ }, [canAttach, draftHandlePaste, showFileRestrictionError, validateSelectedAttachmentFiles] ); + const handleTextareaFocus = useCallback(() => setIsTextareaFocused(true), []); + const handleTextareaBlur = useCallback(() => setIsTextareaFocused(false), []); const remaining = MAX_TEXT_LENGTH - trimmed.length; const hasAttachmentPreviewContent = @@ -666,6 +671,29 @@ export const MessageComposer = ({ Reused recent cross-team request ) : null; + const shouldShowFooterCharCount = remaining < 200; + const shouldShowSavedIndicator = isTextareaFocused && draft.isSaved; + const nonCompactFooterRight = + compactFooterNotice || shouldShowFooterCharCount || shouldShowSavedIndicator ? ( +
+ {compactFooterNotice} + {shouldShowFooterCharCount || shouldShowSavedIndicator ? ( +
+ {shouldShowFooterCharCount ? ( + + {remaining} chars left + + ) : null} + {shouldShowSavedIndicator ? ( + Saved + ) : null} +
+ ) : null} +
+ ) : null; + const composerFooterRight = isCompactLayout ? compactFooterNotice : nonCompactFooterRight; return (
+ {cornerActionPrefix} {/* NOTE: ContextRing disabled — usage formula is inaccurate */} @@ -1108,27 +1139,7 @@ export const MessageComposer = ({
} - footerRight={ - isCompactLayout ? ( - compactFooterNotice - ) : ( -
- {compactFooterNotice} -
- {remaining < 200 ? ( - - {remaining} chars left - - ) : null} - {draft.isSaved ? ( - Saved - ) : null} -
-
- ) - } + footerRight={composerFooterRight} /> diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 54e856b0..ac725343 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -13,6 +13,12 @@ import { Sheet, type SheetRef } from 'react-modal-sheet'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@renderer/components/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta'; import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded'; @@ -33,7 +39,9 @@ import { CheckCheck, ChevronsDownUp, ChevronsUpDown, + Dock, MessageSquare, + MoreHorizontal, PanelBottom, PanelBottomClose, PanelBottomOpen, @@ -807,6 +815,10 @@ export const MessagesPanel = memo(function MessagesPanel({ onPositionChange('bottom-sheet'); }, [onPositionChange]); + const moveToFloatingComposer = useCallback(() => { + onPositionChange('floating-composer'); + }, [onPositionChange]); + const snapBottomSheetTo = useCallback((snapIndex: number) => { setBottomSheetSnapIndex(snapIndex); bottomSheetRef.current?.snapTo(snapIndex); @@ -864,6 +876,53 @@ export const MessagesPanel = memo(function MessagesPanel({ /> ); + const floatingComposerModeControls = ( +
+ + + + + Move to inline + + + + + + Move to bottom sheet + + + + + + Move to sidebar + +
+ ); + const compactComposerSection = ( ); + const floatingComposerSection = ( + + ); + const inlineStatusSection = ( )}
- - - - - - {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'} - - - - - - - - {messagesSearchBarVisible ? 'Hide search' : 'Search messages'} - - - - - - - Move to inline - + + + + + + + + Message actions + + + setMessagesCollapsed((v) => !v)}> + {messagesCollapsed ? ( + + ) : ( + + )} + {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'} + + setMessagesSearchBarVisible((v) => !v)}> + {messagesSearchBarVisible ? ( + + ) : ( + + )} + {messagesSearchBarVisible ? 'Hide search' : 'Search messages'} + + + + Move to inline + + + + Move to bottom sheet + + + + Float composer + + +
{/* Search & filter bar (toggleable) */} @@ -1126,6 +1202,16 @@ export const MessagesPanel = memo(function MessagesPanel({ ); } + if (position === 'floating-composer') { + return ( +
+
+
{floatingComposerSection}
+
+
+ ); + } + if (position === 'bottom-sheet') { if (!mountPoint) { return @@ -1386,6 +1432,23 @@ export const MessagesPanel = memo(function MessagesPanel({ Move to bottom sheet + + + + + Float composer + + + Move to inline + + + + + + Move to bottom sheet + + + + + + Move to sidebar + + + ); + const compactComposerSection = ( ); + const floatingComposerSection = ( + + ); + const inlineStatusSection = ( )}
- - - - - - {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'} - - - - - - - - {messagesSearchBarVisible ? 'Hide search' : 'Search messages'} - - - - - - - Move to inline - + + + + + + + + Message actions + + + setMessagesCollapsed((v) => !v)}> + {messagesCollapsed ? ( + + ) : ( + + )} + {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'} + + setMessagesSearchBarVisible((v) => !v)}> + {messagesSearchBarVisible ? ( + + ) : ( + + )} + {messagesSearchBarVisible ? 'Hide search' : 'Search messages'} + + + + Move to inline + + + + Move to bottom sheet + + + + Float composer + + +
{/* Search & filter bar (toggleable) */} @@ -1126,6 +1202,16 @@ export const MessagesPanel = memo(function MessagesPanel({ ); } + if (position === 'floating-composer') { + return ( +
+
+
{floatingComposerSection}
+
+
+ ); + } + if (position === 'bottom-sheet') { if (!mountPoint) { return @@ -1386,6 +1432,23 @@ export const MessagesPanel = memo(function MessagesPanel({ Move to bottom sheet + + + + + Float composer + + +
{ + refEvents.push(node ? 'node' : 'null'); + return () => { + refEvents.push('cleanup'); + }; + }} + > + tick {tick} +
+
+ + ); + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(); + await flushMicrotasks(); + }); + + await act(async () => { + host.querySelector('button')?.click(); + await flushMicrotasks(); + }); + + expect(host.textContent).toContain('tick 1'); + expect(refEvents.length).toBeLessThan(10); + expect(refEvents).toContain('cleanup'); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); +});