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.
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 (
{card.activeTeams && card.activeTeams.length > 0 && (
@@ -32,9 +39,9 @@ export const RecentProjectCard = ({
-
@@ -42,6 +49,16 @@ export const RecentProjectCard = ({
{card.name}
+ {isDeleted && (
+
+
+
+ Deleted
+
+
+ Project folder no longer exists
+
+ )}
{card.pathSummary && (
@@ -91,21 +108,34 @@ export const RecentProjectCard = ({
tabIndex={0}
onClick={(event) => {
event.stopPropagation();
+ if (isDeleted) {
+ return;
+ }
onOpenPath();
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
+ if (isDeleted) {
+ return;
+ }
onOpenPath();
}
}}
- className="shrink-0 cursor-pointer rounded p-0.5 transition-colors hover:bg-white/5 hover:text-text-secondary"
+ className={cn(
+ 'shrink-0 rounded p-0.5 transition-colors',
+ isDeleted
+ ? 'cursor-not-allowed text-red-300/70'
+ : 'cursor-pointer hover:bg-white/5 hover:text-text-secondary'
+ )}
>
-
Open
+
+ {isDeleted ? 'Project folder no longer exists' : 'Open'}
+
diff --git a/src/main/index.ts b/src/main/index.ts
index a8474c66..4e8069e0 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -353,6 +353,11 @@ async function createOpenCodeRuntimeAdapterRegistry(
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
bridgeEnv.CLAUDE_TEAM_APP_INSTANCE_ID = openCodeManagedHostInstanceId;
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
+ const useHttpMcpBridge = bridgeEnv.CLAUDE_TEAM_OPENCODE_MCP_HTTP === '1';
+ if (!useHttpMcpBridge) {
+ // The OpenCode bridge direct tools/list proof currently requires a local MCP command.
+ delete bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL;
+ }
const applyMcpLaunchSpecEnv = async (
targetEnv: NodeJS.ProcessEnv,
options: { emitProgress?: boolean } = {}
@@ -408,17 +413,19 @@ async function createOpenCodeRuntimeAdapterRegistry(
}`
);
}
- try {
- reportProgress('runtime-mcp-http', 'Starting Agent Teams MCP server...');
- const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted();
- bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
- reportProgress('runtime-mcp-http-ready', 'Agent Teams MCP server is ready...');
- } catch (error) {
- logger.warn(
- `[OpenCode] Runtime adapter bridge MCP HTTP server unavailable: ${
- error instanceof Error ? error.message : String(error)
- }`
- );
+ if (useHttpMcpBridge) {
+ try {
+ reportProgress('runtime-mcp-http', 'Starting Agent Teams MCP server...');
+ const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted();
+ bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
+ reportProgress('runtime-mcp-http-ready', 'Agent Teams MCP server is ready...');
+ } catch (error) {
+ logger.warn(
+ `[OpenCode] Runtime adapter bridge MCP HTTP server unavailable: ${
+ error instanceof Error ? error.message : String(error)
+ }`
+ );
+ }
}
if (!bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) {
await applyMcpLaunchSpecEnv(bridgeEnv, { emitProgress: true });
@@ -427,7 +434,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
reportProgress('runtime-bridge', 'Preparing OpenCode bridge...');
const resolveBridgeCommandEnv = async (): Promise => {
const nextEnv = { ...bridgeEnv };
- if (!bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) {
+ if (!useHttpMcpBridge || !bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) {
return nextEnv;
}
try {
diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts
index 784aa15e..793f57a5 100644
--- a/src/main/services/discovery/ProjectScanner.ts
+++ b/src/main/services/discovery/ProjectScanner.ts
@@ -30,6 +30,7 @@ import {
import {
type PaginatedSessionsResult,
type Project,
+ type ProjectFilesystemState,
type RepositoryGroup,
type SearchSessionsResult,
type Session,
@@ -82,6 +83,21 @@ const SEARCH_PROJECT_CACHE_TTL_MS = 30_000;
// for lookups and navigation; a small cap preserves that behavior without huge payloads.
const MAX_SESSION_IDS_EXPORTED = 200;
+async function resolveProjectFilesystemState(
+ fsProvider: FileSystemProvider,
+ projectPath: string
+): Promise {
+ if (!projectPath.trim()) {
+ return 'deleted';
+ }
+
+ try {
+ return (await fsProvider.exists(projectPath)) ? 'available' : 'deleted';
+ } catch {
+ return 'deleted';
+ }
+}
+
export interface ProjectScannerOptions {
/**
* Directory for the persisted session-list metadata index.
@@ -340,6 +356,7 @@ export class ProjectScanner {
totalSessions,
createdAt: project.createdAt,
mostRecentSession: project.mostRecentSession,
+ filesystemState: project.filesystemState,
},
],
name: project.name,
@@ -360,6 +377,7 @@ export class ProjectScanner {
const encodedId = customPath.replace(/[/\\]/g, '-');
const folderName = customPath.split(/[/\\]/).filter(Boolean).pop() ?? customPath;
const now = Date.now();
+ const filesystemState = await resolveProjectFilesystemState(this.fsProvider, customPath);
groups.push({
id: encodedId,
@@ -374,6 +392,7 @@ export class ProjectScanner {
sessions: [],
totalSessions: 0,
createdAt: now,
+ filesystemState,
},
],
name: folderName,
@@ -550,6 +569,7 @@ export class ProjectScanner {
cwdHint: firstCwd ?? undefined,
sessionPaths,
});
+ const filesystemState = await resolveProjectFilesystemState(this.fsProvider, actualPath);
// Derive name from resolved path — more reliable than decodePath for
// paths containing dashes (e.g. "test-project" encodes lossily).
@@ -564,6 +584,7 @@ export class ProjectScanner {
totalSessions: allSessionIds.length,
createdAt: Math.floor(createdAt),
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
+ filesystemState,
},
];
}
@@ -623,6 +644,10 @@ export class ProjectScanner {
totalSessions: sessionIds.length,
createdAt: Math.floor(createdAt),
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
+ filesystemState: await resolveProjectFilesystemState(
+ this.fsProvider,
+ actualCwd ?? decodedFallback
+ ),
});
}
diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts
index 0458fd1d..bf92c60e 100644
--- a/src/main/services/infrastructure/CliInstallerService.ts
+++ b/src/main/services/infrastructure/CliInstallerService.ts
@@ -501,7 +501,7 @@ export class CliInstallerService {
},
{
providerId: 'opencode',
- displayName: 'OpenCode (75+ LLM providers)',
+ displayName: 'OpenCode (200+ models)',
},
] as const
).map((provider) => ({
diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts
index d79a7f4c..07443151 100644
--- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts
+++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts
@@ -326,7 +326,7 @@ function getProviderDisplayName(providerId: CliProviderId): string {
case 'gemini':
return 'Gemini';
case 'opencode':
- return 'OpenCode (75+ LLM providers)';
+ return 'OpenCode (200+ models)';
}
}
diff --git a/src/main/types/domain.ts b/src/main/types/domain.ts
index 1c2595ab..b0221f56 100644
--- a/src/main/types/domain.ts
+++ b/src/main/types/domain.ts
@@ -44,6 +44,8 @@ export type MessageCategory = 'user' | 'system' | 'hardNoise' | 'ai' | 'compact'
/**
* Project information derived from ~/.claude/projects/ directory.
*/
+export type ProjectFilesystemState = 'available' | 'deleted';
+
export interface Project {
/** Encoded directory name (e.g., "-Users-username-projectname") */
id: string;
@@ -62,6 +64,8 @@ export interface Project {
createdAt: number;
/** Unix timestamp of most recent session activity */
mostRecentSession?: number;
+ /** Filesystem state for the decoded working directory. */
+ filesystemState?: ProjectFilesystemState;
}
/**
@@ -202,6 +206,8 @@ export interface Worktree {
createdAt: number;
/** Unix timestamp of most recent session activity */
mostRecentSession?: number;
+ /** Filesystem state for this worktree path. */
+ filesystemState?: ProjectFilesystemState;
}
/**
diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx
index 2145b21f..d1f60117 100644
--- a/src/renderer/components/dashboard/CliStatusBanner.tsx
+++ b/src/renderer/components/dashboard/CliStatusBanner.tsx
@@ -385,7 +385,7 @@ function getProviderLabel(providerId: CliProviderId): string {
case 'gemini':
return 'Gemini';
case 'opencode':
- return 'OpenCode (75+ LLM providers)';
+ return 'OpenCode (200+ models)';
}
}
diff --git a/src/renderer/components/layout/TeamTabSectionNav.tsx b/src/renderer/components/layout/TeamTabSectionNav.tsx
index 2ba585ff..4cd48f64 100644
--- a/src/renderer/components/layout/TeamTabSectionNav.tsx
+++ b/src/renderer/components/layout/TeamTabSectionNav.tsx
@@ -33,7 +33,7 @@ export const TeamTabSectionNav = ({
if (messagesPanelMode === 'sidebar') {
return section.id !== 'messages' && section.id !== 'claude-logs';
}
- if (messagesPanelMode === 'bottom-sheet') {
+ if (messagesPanelMode === 'bottom-sheet' || messagesPanelMode === 'floating-composer') {
return section.id !== 'messages';
}
return true;
diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx
index 85a49f2d..1416f917 100644
--- a/src/renderer/components/settings/sections/CliStatusSection.tsx
+++ b/src/renderer/components/settings/sections/CliStatusSection.tsx
@@ -123,7 +123,7 @@ function getProviderLabel(providerId: CliProviderId): string {
case 'gemini':
return 'Gemini';
case 'opencode':
- return 'OpenCode (75+ LLM providers)';
+ return 'OpenCode (200+ models)';
}
}
diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx
index b8c900a4..36d545b5 100644
--- a/src/renderer/components/team/TeamDetailView.tsx
+++ b/src/renderer/components/team/TeamDetailView.tsx
@@ -3623,9 +3623,18 @@ export const TeamDetailView = memo(function TeamDetailView({
ref={setMessagesPanelMountPoint}
className="pointer-events-none absolute inset-0 z-30"
/>
- {messagesPanelMode === 'bottom-sheet' && (
+ {messagesPanelMode === 'bottom-sheet' && !graphOpen && (
)}
+ {messagesPanelMode === 'floating-composer' &&
+ isThisTabActive &&
+ isPaneFocused &&
+ !graphOpen && (
+
+ )}
@@ -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 = (
)}
-
-
- setMessagesCollapsed((v) => !v)}
- aria-label={messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
- >
- {messagesCollapsed ? : }
-
-
-
- {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
-
-
-
-
- setMessagesSearchBarVisible((v) => !v)}
- aria-label={
- messagesSearchBarVisible ? 'Hide message search' : 'Show message search'
- }
- >
- {messagesSearchBarVisible ? : }
-
-
-
- {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
;
@@ -1196,114 +1282,74 @@ export const MessagesPanel = memo(function MessagesPanel({
className="ml-auto flex items-center gap-1"
onPointerDown={(e) => e.stopPropagation()}
>
- {messagesUnreadCount > 0 && (
+
-
-
-
+
+
+
+
+
- Mark all as read
+ Message actions
- )}
-
-
- setMessagesCollapsed((value) => !value)}
- aria-label={
- messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'
- }
- >
+
+ {messagesUnreadCount > 0 && (
+
+
+ Mark all as read
+
+ )}
+ setMessagesCollapsed((value) => !value)}>
{messagesCollapsed ? (
-
+
) : (
-
+
)}
-
-
-
- {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
-
-
-
-
- setMessagesSearchBarVisible((value) => !value)}
- aria-label={
- messagesSearchBarVisible ? 'Hide message search' : 'Show message search'
- }
- >
- {messagesSearchBarVisible ? : }
-
-
-
- {messagesSearchBarVisible ? 'Hide search' : 'Search messages'}
-
-
-
-
-
+ {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
+
+
+ setMessagesSearchBarVisible((value) => !value)}
>
+ {messagesSearchBarVisible ? (
+
+ ) : (
+
+ )}
+ {messagesSearchBarVisible ? 'Hide search' : 'Search messages'}
+
+
{isBottomSheetCollapsed ? (
-
+
) : (
-
+
)}
-
-
-
- {isBottomSheetCollapsed ? 'Expand sheet' : 'Collapse sheet'}
-
-
-
-
-
-
-
-
- Move to inline
-
-
-
-
-
-
-
- Move to sidebar
-
+ {isBottomSheetCollapsed ? 'Expand sheet' : 'Collapse sheet'}
+
+
+
+ Move to inline
+
+
+
+ Move to sidebar
+
+
+
+ Float composer
+
+
+
@@ -1386,6 +1432,23 @@ export const MessagesPanel = memo(function MessagesPanel({
Move to bottom sheet
+
+
+ {
+ e.stopPropagation();
+ moveToFloatingComposer();
+ }}
+ aria-label="Float messages composer"
+ >
+
+
+
+ Float composer
+
;
}
@@ -133,12 +134,20 @@ export const Combobox = ({
{
+ if (option.disabled) {
+ return;
+ }
onValueChange(option.value);
setOpen(false);
setSearch('');
}}
- className="relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
+ className={cn(
+ 'relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]',
+ option.disabled && 'cursor-not-allowed opacity-60'
+ )}
>
{renderOption ? (
renderOption(option, isSelected, search)
diff --git a/src/renderer/components/ui/dropdown-menu.tsx b/src/renderer/components/ui/dropdown-menu.tsx
new file mode 100644
index 00000000..d1b46d96
--- /dev/null
+++ b/src/renderer/components/ui/dropdown-menu.tsx
@@ -0,0 +1,65 @@
+/* eslint-disable react/jsx-props-no-spreading -- Standard shadcn pattern: forward remaining props to underlying elements */
+import * as React from 'react';
+
+import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
+import { cn } from '@renderer/lib/utils';
+
+const DropdownMenu = DropdownMenuPrimitive.Root;
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
+
+const DropdownMenuContent = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+));
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
+
+const DropdownMenuItem = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
+
+export {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+};
+/* eslint-enable react/jsx-props-no-spreading -- Re-enable after shadcn component */
diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts
index d8213c10..40054525 100644
--- a/src/renderer/store/slices/cliInstallerSlice.ts
+++ b/src/renderer/store/slices/cliInstallerSlice.ts
@@ -38,7 +38,7 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
{ providerId: 'anthropic', displayName: 'Anthropic' },
{ providerId: 'codex', displayName: 'Codex' },
{ providerId: 'gemini', displayName: 'Gemini' },
- { providerId: 'opencode', displayName: 'OpenCode (75+ LLM providers)' },
+ { providerId: 'opencode', displayName: 'OpenCode (200+ models)' },
] as const
).map((provider) => ({
...provider,
@@ -500,7 +500,7 @@ function getProviderDisplayName(providerId: CliProviderId): string {
case 'gemini':
return 'Gemini';
case 'opencode':
- return 'OpenCode (75+ LLM providers)';
+ return 'OpenCode (200+ models)';
}
}
diff --git a/src/renderer/types/teamMessagesPanelMode.ts b/src/renderer/types/teamMessagesPanelMode.ts
index 87fbaaf1..85f90ec3 100644
--- a/src/renderer/types/teamMessagesPanelMode.ts
+++ b/src/renderer/types/teamMessagesPanelMode.ts
@@ -1 +1 @@
-export type TeamMessagesPanelMode = 'sidebar' | 'inline' | 'bottom-sheet';
+export type TeamMessagesPanelMode = 'sidebar' | 'inline' | 'bottom-sheet' | 'floating-composer';
diff --git a/test/features/recent-projects/core/domain/mergeRecentProjectCandidates.test.ts b/test/features/recent-projects/core/domain/mergeRecentProjectCandidates.test.ts
index fd6e655e..556949c6 100644
--- a/test/features/recent-projects/core/domain/mergeRecentProjectCandidates.test.ts
+++ b/test/features/recent-projects/core/domain/mergeRecentProjectCandidates.test.ts
@@ -96,4 +96,40 @@ describe('mergeRecentProjectCandidates', () => {
expect(result[0].identity).toBe('repo:beta');
expect(result[0].branchName).toBeUndefined();
});
+
+ it('prefers an available candidate over a newer deleted path', () => {
+ const result = mergeRecentProjectCandidates([
+ makeCandidate({
+ lastActivityAt: 1_000,
+ primaryPath: '/workspace/alpha',
+ associatedPaths: ['/workspace/alpha'],
+ filesystemState: 'available',
+ openTarget: {
+ type: 'synthetic-path',
+ path: '/workspace/alpha',
+ },
+ }),
+ makeCandidate({
+ lastActivityAt: 5_000,
+ primaryPath: '/workspace/alpha-deleted',
+ associatedPaths: ['/workspace/alpha-deleted'],
+ filesystemState: 'deleted',
+ openTarget: {
+ type: 'synthetic-path',
+ path: '/workspace/alpha-deleted',
+ },
+ }),
+ ]);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({
+ primaryPath: '/workspace/alpha',
+ lastActivityAt: 5_000,
+ filesystemState: 'available',
+ openTarget: {
+ type: 'synthetic-path',
+ path: '/workspace/alpha',
+ },
+ });
+ });
});
diff --git a/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts b/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts
index afc4418b..0e429ea5 100644
--- a/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts
+++ b/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts
@@ -114,6 +114,42 @@ describe('CodexSessionFileRecentProjectsSourceAdapter', () => {
expect(identityResolver.resolve).toHaveBeenCalledWith('/Users/test/projects/alpha');
});
+ it('marks a Codex session project as deleted when its cwd is gone', async () => {
+ const codexHome = path.join(tempDir, '.codex');
+ const logger = createLogger();
+ const identityResolver = {
+ resolve: vi.fn().mockResolvedValue(null),
+ } as unknown as RecentProjectIdentityResolver;
+ const fsProvider = {
+ exists: vi.fn().mockResolvedValue(false),
+ };
+ await writeRollout(
+ path.join(codexHome, 'sessions', '2026', '04', '14', 'rollout-deleted.jsonl'),
+ {
+ cwd: '/Users/test/projects/deleted',
+ },
+ new Date('2026-04-14T12:00:00.000Z')
+ );
+
+ const adapter = new CodexSessionFileRecentProjectsSourceAdapter({
+ getActiveContext: () => ({ type: 'local', id: 'local-1', fsProvider }) as never,
+ getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never,
+ identityResolver,
+ logger,
+ codexHome,
+ });
+
+ const result = await adapter.list();
+
+ expect(result.candidates[0]).toEqual(
+ expect.objectContaining({
+ primaryPath: '/Users/test/projects/deleted',
+ filesystemState: 'deleted',
+ })
+ );
+ expect(fsProvider.exists).toHaveBeenCalledWith('/Users/test/projects/deleted');
+ });
+
it('loads Codex projects from large session metadata lines without parsing the full line', async () => {
const codexHome = path.join(tempDir, '.codex');
const logger = createLogger();
diff --git a/test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts b/test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts
index 90e65166..35ee6e31 100644
--- a/test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts
+++ b/test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts
@@ -21,6 +21,7 @@ describe('adaptRecentProjectsSection', () => {
worktreeId: 'wt-alpha',
},
primaryBranch: 'main',
+ filesystemState: 'deleted',
};
const activeTeam: TeamSummary = {
@@ -52,11 +53,11 @@ describe('adaptRecentProjectsSection', () => {
taskCounts: { pending: 5, inProgress: 7, completed: 9 },
additionalPathCount: 1,
primaryBranch: 'main',
+ filesystemState: 'deleted',
activeTeams: [activeTeam],
pathSummary: {
badgeLabel: '2 paths',
- description:
- 'This card merges recent activity from related worktrees and project paths.',
+ description: 'This card merges recent activity from related worktrees and project paths.',
paths: [
{
label: 'Primary path',
diff --git a/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts b/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts
index a26ddc14..1d5047d3 100644
--- a/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts
+++ b/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts
@@ -153,4 +153,26 @@ describe('ProjectScanner cwd split logic', () => {
expect(worktree?.isMainWorktree).toBe(false);
expect(worktree?.source).toBe('claude-desktop');
});
+
+ it('marks decoded project paths as deleted when the working directory no longer exists', async () => {
+ const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-'));
+ tempDirs.push(projectsDir);
+
+ const encodedName = '-Users-test-deleted-project';
+ const projectDir = path.join(projectsDir, encodedName);
+ fs.mkdirSync(projectDir);
+
+ fs.writeFileSync(
+ path.join(projectDir, 'session-deleted.jsonl'),
+ createSessionLine({ cwd: '/Users/test/deleted-project' }) + '\n'
+ );
+
+ const scanner = new ProjectScanner(projectsDir);
+ const projects = await scanner.scan();
+
+ expect(projects[0]).toMatchObject({
+ path: '/Users/test/deleted-project',
+ filesystemState: 'deleted',
+ });
+ });
});
diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts
index 39e08c66..140454b7 100644
--- a/test/main/services/infrastructure/CliInstallerService.test.ts
+++ b/test/main/services/infrastructure/CliInstallerService.test.ts
@@ -151,7 +151,7 @@ describe('CliInstallerService', () => {
'opencode',
]);
expect(openCodeStatus).toMatchObject({
- displayName: 'OpenCode (75+ LLM providers)',
+ displayName: 'OpenCode (200+ models)',
supported: false,
statusMessage: 'Runtime not found.',
canLoginFromUi: false,
diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts
index dd039aec..df94eae6 100644
--- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts
+++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts
@@ -221,7 +221,7 @@ describe('ClaudeMultimodelBridgeService', () => {
});
expect(providers[3]).toMatchObject({
providerId: 'opencode',
- displayName: 'OpenCode (75+ LLM providers)',
+ displayName: 'OpenCode (200+ models)',
supported: false,
authenticated: false,
models: [],
diff --git a/test/renderer/components/cli/CliStatusVisibility.test.ts b/test/renderer/components/cli/CliStatusVisibility.test.ts
index ff2affe4..061853eb 100644
--- a/test/renderer/components/cli/CliStatusVisibility.test.ts
+++ b/test/renderer/components/cli/CliStatusVisibility.test.ts
@@ -450,7 +450,7 @@ describe('CLI status visibility during completed install state', () => {
providers: [
{
providerId: 'opencode',
- displayName: 'OpenCode (75+ LLM providers)',
+ displayName: 'OpenCode (200+ models)',
supported: false,
authenticated: false,
authMethod: null,
@@ -476,7 +476,7 @@ describe('CLI status visibility during completed install state', () => {
await Promise.resolve();
});
- expect(host.textContent).toContain('OpenCode (75+ LLM providers)');
+ expect(host.textContent).toContain('OpenCode (200+ models)');
expect(host.textContent).toContain('Install');
const installButton = Array.from(host.querySelectorAll('button')).find(
@@ -523,7 +523,7 @@ describe('CLI status visibility during completed install state', () => {
providers: [
{
providerId: 'opencode',
- displayName: 'OpenCode (75+ LLM providers)',
+ displayName: 'OpenCode (200+ models)',
supported: false,
authenticated: false,
authMethod: null,
@@ -575,7 +575,7 @@ describe('CLI status visibility during completed install state', () => {
providers: [
{
providerId: 'opencode',
- displayName: 'OpenCode (75+ LLM providers)',
+ displayName: 'OpenCode (200+ models)',
supported: true,
authenticated: true,
authMethod: 'opencode_managed',
diff --git a/test/renderer/components/extensions/skills/SkillsPanel.test.ts b/test/renderer/components/extensions/skills/SkillsPanel.test.ts
index a19ce837..b71bae74 100644
--- a/test/renderer/components/extensions/skills/SkillsPanel.test.ts
+++ b/test/renderer/components/extensions/skills/SkillsPanel.test.ts
@@ -559,7 +559,7 @@ describe('SkillsPanel', () => {
});
expect(host.textContent).toContain(
- 'Shared skills in `.claude`, `.cursor`, and `.agents` are available to Anthropic, Codex, and OpenCode (75+ LLM providers).'
+ 'Shared skills in `.claude`, `.cursor`, and `.agents` are available to Anthropic, Codex, and OpenCode (200+ models).'
);
expect(host.textContent).toContain('Codex only');
diff --git a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts
index 4f794a3a..d8d33952 100644
--- a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts
+++ b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts
@@ -1405,6 +1405,12 @@ describe('LaunchTeamDialog', () => {
expect(host.textContent).toContain('model:claude-opus-4-6');
expect(host.textContent).toContain('effort:max');
expect(host.textContent).toContain('fast:on');
+ expect(host.textContent).toContain('monthly Agent SDK credit');
+ expect(
+ host.querySelector(
+ 'a[href="https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan"]'
+ )
+ ).toBeTruthy();
const submitButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent === 'Save Changes'
diff --git a/test/renderer/components/team/dialogs/projectPathOptions.test.ts b/test/renderer/components/team/dialogs/projectPathOptions.test.ts
index 33685f88..2af0d127 100644
--- a/test/renderer/components/team/dialogs/projectPathOptions.test.ts
+++ b/test/renderer/components/team/dialogs/projectPathOptions.test.ts
@@ -88,4 +88,27 @@ describe('buildProjectPathOptions', () => {
},
]);
});
+
+ it('marks deleted project paths as disabled options', () => {
+ const options = buildProjectPathOptions([
+ createProject({
+ id: 'project-deleted',
+ name: 'my-tes',
+ path: '/Users/belief/dev/projects/my-tes',
+ filesystemState: 'deleted',
+ }),
+ ]);
+
+ expect(options).toEqual([
+ {
+ value: '/Users/belief/dev/projects/my-tes',
+ label: 'my-tes',
+ description: '/Users/belief/dev/projects/my-tes',
+ disabled: true,
+ meta: {
+ filesystemState: 'deleted',
+ },
+ },
+ ]);
+ });
});
From ddfa4cc59d5d963480431eb964a97d67503da67a Mon Sep 17 00:00:00 2001
From: infiniti <52129260+developerInfiniti@users.noreply.github.com>
Date: Sat, 16 May 2026 15:04:32 +0300
Subject: [PATCH 3/9] fix(windows): harden packaging and codex smoke
---
scripts/electron-builder/afterPack.cjs | 107 +++++++++++++++++-
scripts/electron-builder/verifyBundle.cjs | 26 ++++-
scripts/smoke/agent-attachments-smoke.mjs | 86 +++++++++++++-
.../build/electronBuilderAfterPack.test.ts | 72 +++++++++++-
4 files changed, 281 insertions(+), 10 deletions(-)
diff --git a/scripts/electron-builder/afterPack.cjs b/scripts/electron-builder/afterPack.cjs
index c4ca053c..3ddcff23 100644
--- a/scripts/electron-builder/afterPack.cjs
+++ b/scripts/electron-builder/afterPack.cjs
@@ -159,6 +159,93 @@ async function pruneNodePtyArtifacts(appOutDir, platform, archLabel) {
return removedPaths;
}
+function findNodeModulesSequence(segments, sequence) {
+ for (let index = 0; index <= segments.length - sequence.length; index += 1) {
+ let matches = true;
+ for (let offset = 0; offset < sequence.length; offset += 1) {
+ if (segments[index + offset] !== sequence[offset]) {
+ matches = false;
+ break;
+ }
+ }
+ if (matches) {
+ return index;
+ }
+ }
+ return -1;
+}
+
+function getKnownPrunableNativeArtifactRoot(appOutDir, filePath, targetPlatform, targetArch) {
+ if (targetPlatform !== 'win32') {
+ return null;
+ }
+
+ const relativePath = path.relative(appOutDir, filePath);
+ const segments = relativePath.split(path.sep);
+
+ const conptyIndex = findNodeModulesSequence(segments, [
+ 'node_modules',
+ 'node-pty',
+ 'third_party',
+ 'conpty',
+ ]);
+ const conptyArchIndex = conptyIndex + 5;
+ const conptyArchDir = conptyIndex >= 0 ? segments[conptyArchIndex] : null;
+ if (conptyArchDir?.startsWith('win10-') && conptyArchDir !== `win10-${targetArch}`) {
+ return path.join(appOutDir, ...segments.slice(0, conptyArchIndex + 1));
+ }
+
+ return null;
+}
+
+function isKnownAllowedNativeMismatch(relativePath, format, archs, targetPlatform) {
+ const normalizedPath = relativePath.split(path.sep).join('/');
+ const ssh2PageantPath = 'node_modules/ssh2/util/pagent.exe';
+
+ return (
+ targetPlatform === 'win32' &&
+ (normalizedPath === ssh2PageantPath || normalizedPath.endsWith(`/${ssh2PageantPath}`)) &&
+ format === 'pe' &&
+ archs.size === 1 &&
+ archs.has('ia32')
+ );
+}
+
+async function pruneKnownIncompatibleNativeArtifacts(appOutDir, targetPlatform, targetArch) {
+ const files = await walkFiles(appOutDir);
+ const rootsToRemove = new Set();
+
+ for (const filePath of files) {
+ const rootToRemove = getKnownPrunableNativeArtifactRoot(
+ appOutDir,
+ filePath,
+ targetPlatform,
+ targetArch
+ );
+ if (!rootToRemove) {
+ continue;
+ }
+
+ const metadata = await detectBinaryMetadata(filePath);
+ if (!metadata) {
+ continue;
+ }
+
+ if (isBinaryCompatible(metadata.format, metadata.archs, targetPlatform, targetArch)) {
+ continue;
+ }
+
+ rootsToRemove.add(rootToRemove);
+ }
+
+ const removedPaths = [];
+ for (const absolutePath of rootsToRemove) {
+ await fs.promises.rm(absolutePath, { recursive: true, force: true });
+ removedPaths.push(absolutePath);
+ }
+ return removedPaths;
+}
+
function mapMachOCpuType(cpuType) {
switch (cpuType >>> 0) {
case 0x00000007:
@@ -335,6 +422,7 @@ async function validateNativeBinaries(appOutDir, targetPlatform, targetArch) {
const files = await walkFiles(appOutDir);
for (const filePath of files) {
+ const relativePath = path.relative(appOutDir, filePath);
const metadata = await detectBinaryMetadata(filePath);
if (!metadata) {
continue;
@@ -344,8 +432,14 @@ async function validateNativeBinaries(appOutDir, targetPlatform, targetArch) {
continue;
}
+ if (
+ isKnownAllowedNativeMismatch(relativePath, metadata.format, metadata.archs, targetPlatform)
+ ) {
+ continue;
+ }
+
mismatches.push({
- path: path.relative(appOutDir, filePath),
+ path: relativePath,
format: metadata.format,
archs: [...metadata.archs].sort(),
});
@@ -358,7 +452,14 @@ async function afterPack(context) {
const targetPlatform = context.electronPlatformName;
const targetArch = getArchLabel(context.arch);
- const removedPaths = await pruneNodePtyArtifacts(context.appOutDir, targetPlatform, targetArch);
+ const removedPaths = [
+ ...(await pruneNodePtyArtifacts(context.appOutDir, targetPlatform, targetArch)),
+ ...(await pruneKnownIncompatibleNativeArtifacts(
+ context.appOutDir,
+ targetPlatform,
+ targetArch
+ )),
+ ];
const mismatches = await validateNativeBinaries(context.appOutDir, targetPlatform, targetArch);
if (mismatches.length > 0) {
@@ -383,9 +484,11 @@ module.exports._internal = {
detectBinaryMetadata,
getArchLabel,
isBinaryCompatible,
+ isKnownAllowedNativeMismatch,
parseElf,
parseMachO,
parsePortableExecutable,
+ pruneKnownIncompatibleNativeArtifacts,
pruneNodePtyArtifacts,
validateNativeBinaries,
walkFiles,
diff --git a/scripts/electron-builder/verifyBundle.cjs b/scripts/electron-builder/verifyBundle.cjs
index 29ec81db..c4e68520 100644
--- a/scripts/electron-builder/verifyBundle.cjs
+++ b/scripts/electron-builder/verifyBundle.cjs
@@ -4,6 +4,18 @@ const afterPackModule = require('./afterPack.cjs');
const { validateNativeBinaries } = afterPackModule._internal;
+function isAllowedPostPackMismatch(mismatch, platform, arch) {
+ const relativePath = mismatch.path.split(path.sep).join('/');
+ return (
+ platform === 'win32' &&
+ arch === 'x64' &&
+ relativePath === 'resources/elevate.exe' &&
+ mismatch.format === 'pe' &&
+ mismatch.archs.length === 1 &&
+ mismatch.archs[0] === 'ia32'
+ );
+}
+
async function main() {
const [bundlePathArg, platform, arch] = process.argv.slice(2);
@@ -14,16 +26,22 @@ async function main() {
const bundlePath = path.resolve(bundlePathArg);
const mismatches = await validateNativeBinaries(bundlePath, platform, arch);
+ const blockingMismatches = mismatches.filter(
+ (mismatch) => !isAllowedPostPackMismatch(mismatch, platform, arch)
+ );
- if (mismatches.length === 0) {
- console.log(`[verifyBundle] OK ${platform}-${arch}: ${bundlePath}`);
+ if (blockingMismatches.length === 0) {
+ const allowedCount = mismatches.length - blockingMismatches.length;
+ const suffix =
+ allowedCount > 0 ? ` (${allowedCount} allowed post-pack helper mismatch ignored)` : '';
+ console.log(`[verifyBundle] OK ${platform}-${arch}: ${bundlePath}${suffix}`);
return;
}
console.error(
- `[verifyBundle] Found ${mismatches.length} incompatible native binaries in ${platform}-${arch}: ${bundlePath}`
+ `[verifyBundle] Found ${blockingMismatches.length} incompatible native binaries in ${platform}-${arch}: ${bundlePath}`
);
- for (const mismatch of mismatches.slice(0, 50)) {
+ for (const mismatch of blockingMismatches.slice(0, 50)) {
console.error(`- ${mismatch.path} [${mismatch.format}] -> ${mismatch.archs.join(', ')}`);
}
process.exit(1);
diff --git a/scripts/smoke/agent-attachments-smoke.mjs b/scripts/smoke/agent-attachments-smoke.mjs
index dc83b686..13f323d2 100644
--- a/scripts/smoke/agent-attachments-smoke.mjs
+++ b/scripts/smoke/agent-attachments-smoke.mjs
@@ -1,5 +1,6 @@
#!/usr/bin/env node
-import { spawn } from 'node:child_process';
+import { spawn, spawnSync } from 'node:child_process';
+import { existsSync } from 'node:fs';
import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
@@ -326,12 +327,93 @@ function parseArgs(argv) {
return { all, jsonPath, list, selected };
}
+function hasPathSeparator(value) {
+ return value.includes('/') || value.includes('\\');
+}
+
+function resolveWindowsSpawnBinary(binary) {
+ if (process.platform !== 'win32') {
+ return binary;
+ }
+
+ if (hasPathSeparator(binary)) {
+ if (!path.extname(binary) && existsSync(`${binary}.cmd`)) {
+ return `${binary}.cmd`;
+ }
+ return binary;
+ }
+
+ const whereResult = spawnSync('where.exe', [binary], {
+ encoding: 'utf8',
+ windowsHide: true,
+ });
+ if (whereResult.status !== 0 || !whereResult.stdout) {
+ return binary;
+ }
+
+ const candidates = whereResult.stdout
+ .split(/\r?\n/)
+ .map((candidate) => candidate.trim())
+ .filter(Boolean);
+ const extensionlessShim = candidates.find(
+ (candidate) => !path.extname(candidate) && existsSync(`${candidate}.cmd`)
+ );
+ if (extensionlessShim) {
+ return `${extensionlessShim}.cmd`;
+ }
+ return (
+ candidates.find((candidate) => /\.exe$/i.test(candidate)) ??
+ candidates.find((candidate) => /\.(?:cmd|bat)$/i.test(candidate)) ??
+ candidates[0] ??
+ binary
+ );
+}
+
+function quoteWindowsCmdArg(value) {
+ const text = String(value);
+ if (text.length === 0) {
+ return '""';
+ }
+ if (!/[ \t\r\n"&|<>^()%!]/.test(text)) {
+ return text;
+ }
+ return `"${text.replace(/%/g, '%%').replace(/(["^&|<>])/g, '^$1')}"`;
+}
+
+function buildSpawnInvocation(command) {
+ if (process.platform !== 'win32') {
+ return {
+ bin: command.bin,
+ args: command.args,
+ options: { windowsHide: true },
+ };
+ }
+
+ const resolvedBin = resolveWindowsSpawnBinary(command.bin);
+ if (/\.(?:cmd|bat)$/i.test(resolvedBin)) {
+ const commandLine = [resolvedBin, ...command.args].map(quoteWindowsCmdArg).join(' ');
+ return {
+ bin: process.env.ComSpec || 'cmd.exe',
+ args: ['/d', '/s', '/c', commandLine],
+ options: { windowsHide: true, windowsVerbatimArguments: true },
+ };
+ }
+
+ return {
+ bin: resolvedBin,
+ args: command.args,
+ options: { windowsHide: true },
+ };
+}
+
function runCommand(command) {
return new Promise((resolve) => {
- const child = spawn(command.bin, command.args, {
+ const spawnInvocation = buildSpawnInvocation(command);
+ const child = spawn(spawnInvocation.bin, spawnInvocation.args, {
stdio: ['pipe', 'pipe', 'pipe'],
env: process.env,
cwd: command.cwd,
+ ...spawnInvocation.options,
});
let stdout = '';
let stderr = '';
diff --git a/test/main/build/electronBuilderAfterPack.test.ts b/test/main/build/electronBuilderAfterPack.test.ts
index 7b8428e4..8835dbfc 100644
--- a/test/main/build/electronBuilderAfterPack.test.ts
+++ b/test/main/build/electronBuilderAfterPack.test.ts
@@ -45,8 +45,8 @@ function createElfBuffer(arch: 'arm64' | 'x64'): Buffer {
return buffer;
}
-function createPortableExecutableBuffer(arch: 'arm64' | 'x64'): Buffer {
- const machine = arch === 'arm64' ? 0xaa64 : 0x8664;
+function createPortableExecutableBuffer(arch: 'arm64' | 'ia32' | 'x64'): Buffer {
+ const machine = arch === 'arm64' ? 0xaa64 : arch === 'ia32' ? 0x014c : 0x8664;
const buffer = Buffer.alloc(256);
buffer[0] = 0x4d;
buffer[1] = 0x5a;
@@ -224,4 +224,72 @@ describe('electron-builder afterPack', () => {
)
).toBe(false);
});
+
+ it('accepts a clean x64 Windows bundle with optional standalone helper binaries', async () => {
+ const tempDir = createTempDir();
+ tempDirs.push(tempDir);
+
+ writeFile(path.join(tempDir, 'Agent Teams UI.exe'), createPortableExecutableBuffer('x64'));
+ writeFile(
+ path.join(
+ tempDir,
+ 'resources',
+ 'app.asar.unpacked',
+ 'node_modules',
+ 'node-pty',
+ 'build',
+ 'Release',
+ 'pty.node'
+ ),
+ createPortableExecutableBuffer('x64')
+ );
+ const pageantPath = path.join(
+ tempDir,
+ 'resources',
+ 'app.asar.unpacked',
+ 'node_modules',
+ 'ssh2',
+ 'util',
+ 'pagent.exe'
+ );
+ const armConptyDir = path.join(
+ tempDir,
+ 'resources',
+ 'app.asar.unpacked',
+ 'node_modules',
+ 'node-pty',
+ 'third_party',
+ 'conpty',
+ '1.23.251008001',
+ 'win10-arm64'
+ );
+ writeFile(pageantPath, createPortableExecutableBuffer('ia32'));
+ writeFile(path.join(armConptyDir, 'conpty.dll'), createPortableExecutableBuffer('arm64'));
+ writeFile(path.join(armConptyDir, 'OpenConsole.exe'), createPortableExecutableBuffer('arm64'));
+
+ await afterPackModule({
+ appOutDir: tempDir,
+ electronPlatformName: 'win32',
+ arch: 1,
+ });
+
+ expect(fs.existsSync(pageantPath)).toBe(true);
+ expect(fs.existsSync(armConptyDir)).toBe(false);
+ });
+
+ it('still reports unrelated ia32 Windows binaries in an x64 bundle', async () => {
+ const tempDir = createTempDir();
+ tempDirs.push(tempDir);
+
+ const badBinaryPath = path.join(tempDir, 'resources', 'app.asar.unpacked', 'bad-helper.exe');
+ writeFile(badBinaryPath, createPortableExecutableBuffer('ia32'));
+
+ await expect(validateNativeBinaries(tempDir, 'win32', 'x64')).resolves.toEqual([
+ {
+ path: path.join('resources', 'app.asar.unpacked', 'bad-helper.exe'),
+ format: 'pe',
+ archs: ['ia32'],
+ },
+ ]);
+ });
});
From 09db3abfcd7a33bd3f497686ef2a46d46e11ff6b Mon Sep 17 00:00:00 2001
From: infiniti <52129260+developerInfiniti@users.noreply.github.com>
Date: Sat, 16 May 2026 17:34:50 +0300
Subject: [PATCH 4/9] fix: harden Windows frontend path handling
Harden Windows path handling and packaged app smoke checks.
---
scripts/electron-builder/afterPack.cjs | 107 +++++++++++++++++-
scripts/electron-builder/dist-invocations.cjs | 51 +++++++++
scripts/electron-builder/dist.mjs | 52 +++++++++
scripts/electron-builder/smokePackagedApp.cjs | 56 ++++++++-
scripts/electron-builder/verifyBundle.cjs | 26 ++++-
scripts/smoke/agent-attachments-smoke.mjs | 86 +++++++++++++-
.../utils/recentProjectOpenHistory.ts | 3 +-
src/main/services/editor/FileSearchService.ts | 4 +-
.../DirectoryTree/buildDirectoryTree.ts | 11 +-
.../components/chat/viewers/FileLink.tsx | 15 ++-
.../chat/viewers/MarkdownViewer.tsx | 6 +-
.../settings/sections/GeneralSection.tsx | 4 +-
.../team/editor/EditorContextMenu.tsx | 9 +-
.../team/editor/SearchInFilesPanel.tsx | 8 +-
src/renderer/hooks/useFileSuggestions.ts | 25 ++--
src/renderer/store/utils/pathResolution.ts | 29 ++---
src/renderer/utils/claudeMdTracker.ts | 69 ++++++-----
src/renderer/utils/contextTracker.ts | 99 ++++++++++------
src/renderer/utils/pathDisplay.ts | 24 ++--
src/shared/utils/platformPath.ts | 21 ++++
.../utils/recentProjectOpenHistory.test.ts | 19 ++++
.../build/electronBuilderAfterPack.test.ts | 72 +++++++++++-
.../build/electronBuilderDistScript.test.ts | 44 +++++++
.../services/editor/FileSearchService.test.ts | 25 ++++
.../buildDirectoryTree.test.ts | 47 ++++++++
.../chat/viewers/MarkdownViewer.test.tsx | 25 +++-
test/renderer/components/fileLink.test.ts | 15 +++
.../renderer/hooks/useFileSuggestions.test.ts | 41 ++++++-
test/renderer/store/pathResolution.test.ts | 24 ++++
test/renderer/utils/claudeMdTracker.test.ts | 103 +++++++++++++++++
test/renderer/utils/contextTracker.test.ts | 66 +++++++++++
test/renderer/utils/pathDisplay.test.ts | 27 ++++-
test/shared/utils/platformPath.test.ts | 38 +++++++
33 files changed, 1088 insertions(+), 163 deletions(-)
create mode 100644 scripts/electron-builder/dist-invocations.cjs
create mode 100644 scripts/electron-builder/dist.mjs
create mode 100644 test/main/build/electronBuilderDistScript.test.ts
create mode 100644 test/renderer/components/chat/SessionContextPanel/buildDirectoryTree.test.ts
create mode 100644 test/renderer/utils/contextTracker.test.ts
create mode 100644 test/shared/utils/platformPath.test.ts
diff --git a/scripts/electron-builder/afterPack.cjs b/scripts/electron-builder/afterPack.cjs
index c4ca053c..3ddcff23 100644
--- a/scripts/electron-builder/afterPack.cjs
+++ b/scripts/electron-builder/afterPack.cjs
@@ -159,6 +159,93 @@ async function pruneNodePtyArtifacts(appOutDir, platform, archLabel) {
return removedPaths;
}
+function findNodeModulesSequence(segments, sequence) {
+ for (let index = 0; index <= segments.length - sequence.length; index += 1) {
+ let matches = true;
+ for (let offset = 0; offset < sequence.length; offset += 1) {
+ if (segments[index + offset] !== sequence[offset]) {
+ matches = false;
+ break;
+ }
+ }
+ if (matches) {
+ return index;
+ }
+ }
+ return -1;
+}
+
+function getKnownPrunableNativeArtifactRoot(appOutDir, filePath, targetPlatform, targetArch) {
+ if (targetPlatform !== 'win32') {
+ return null;
+ }
+
+ const relativePath = path.relative(appOutDir, filePath);
+ const segments = relativePath.split(path.sep);
+
+ const conptyIndex = findNodeModulesSequence(segments, [
+ 'node_modules',
+ 'node-pty',
+ 'third_party',
+ 'conpty',
+ ]);
+ const conptyArchIndex = conptyIndex + 5;
+ const conptyArchDir = conptyIndex >= 0 ? segments[conptyArchIndex] : null;
+ if (conptyArchDir?.startsWith('win10-') && conptyArchDir !== `win10-${targetArch}`) {
+ return path.join(appOutDir, ...segments.slice(0, conptyArchIndex + 1));
+ }
+
+ return null;
+}
+
+function isKnownAllowedNativeMismatch(relativePath, format, archs, targetPlatform) {
+ const normalizedPath = relativePath.split(path.sep).join('/');
+ const ssh2PageantPath = 'node_modules/ssh2/util/pagent.exe';
+
+ return (
+ targetPlatform === 'win32' &&
+ (normalizedPath === ssh2PageantPath || normalizedPath.endsWith(`/${ssh2PageantPath}`)) &&
+ format === 'pe' &&
+ archs.size === 1 &&
+ archs.has('ia32')
+ );
+}
+
+async function pruneKnownIncompatibleNativeArtifacts(appOutDir, targetPlatform, targetArch) {
+ const files = await walkFiles(appOutDir);
+ const rootsToRemove = new Set();
+
+ for (const filePath of files) {
+ const rootToRemove = getKnownPrunableNativeArtifactRoot(
+ appOutDir,
+ filePath,
+ targetPlatform,
+ targetArch
+ );
+ if (!rootToRemove) {
+ continue;
+ }
+
+ const metadata = await detectBinaryMetadata(filePath);
+ if (!metadata) {
+ continue;
+ }
+
+ if (isBinaryCompatible(metadata.format, metadata.archs, targetPlatform, targetArch)) {
+ continue;
+ }
+
+ rootsToRemove.add(rootToRemove);
+ }
+
+ const removedPaths = [];
+ for (const absolutePath of rootsToRemove) {
+ await fs.promises.rm(absolutePath, { recursive: true, force: true });
+ removedPaths.push(absolutePath);
+ }
+ return removedPaths;
+}
+
function mapMachOCpuType(cpuType) {
switch (cpuType >>> 0) {
case 0x00000007:
@@ -335,6 +422,7 @@ async function validateNativeBinaries(appOutDir, targetPlatform, targetArch) {
const files = await walkFiles(appOutDir);
for (const filePath of files) {
+ const relativePath = path.relative(appOutDir, filePath);
const metadata = await detectBinaryMetadata(filePath);
if (!metadata) {
continue;
@@ -344,8 +432,14 @@ async function validateNativeBinaries(appOutDir, targetPlatform, targetArch) {
continue;
}
+ if (
+ isKnownAllowedNativeMismatch(relativePath, metadata.format, metadata.archs, targetPlatform)
+ ) {
+ continue;
+ }
+
mismatches.push({
- path: path.relative(appOutDir, filePath),
+ path: relativePath,
format: metadata.format,
archs: [...metadata.archs].sort(),
});
@@ -358,7 +452,14 @@ async function afterPack(context) {
const targetPlatform = context.electronPlatformName;
const targetArch = getArchLabel(context.arch);
- const removedPaths = await pruneNodePtyArtifacts(context.appOutDir, targetPlatform, targetArch);
+ const removedPaths = [
+ ...(await pruneNodePtyArtifacts(context.appOutDir, targetPlatform, targetArch)),
+ ...(await pruneKnownIncompatibleNativeArtifacts(
+ context.appOutDir,
+ targetPlatform,
+ targetArch
+ )),
+ ];
const mismatches = await validateNativeBinaries(context.appOutDir, targetPlatform, targetArch);
if (mismatches.length > 0) {
@@ -383,9 +484,11 @@ module.exports._internal = {
detectBinaryMetadata,
getArchLabel,
isBinaryCompatible,
+ isKnownAllowedNativeMismatch,
parseElf,
parseMachO,
parsePortableExecutable,
+ pruneKnownIncompatibleNativeArtifacts,
pruneNodePtyArtifacts,
validateNativeBinaries,
walkFiles,
diff --git a/scripts/electron-builder/dist-invocations.cjs b/scripts/electron-builder/dist-invocations.cjs
new file mode 100644
index 00000000..a8f7db1c
--- /dev/null
+++ b/scripts/electron-builder/dist-invocations.cjs
@@ -0,0 +1,51 @@
+const PLATFORM_FLAGS = new Map([
+ ['--mac', 'mac'],
+ ['-m', 'mac'],
+ ['--win', 'win'],
+ ['-w', 'win'],
+ ['--linux', 'linux'],
+ ['-l', 'linux'],
+]);
+
+const PLATFORM_ARGS = {
+ mac: '--mac',
+ win: '--win',
+ linux: '--linux',
+};
+
+const LINUX_PACKAGE_NAME_OVERRIDES = [
+ '--config.productName=Agent-Teams-UI',
+ '--config.linux.desktop.entry.Name=Agent Teams UI',
+];
+
+function buildElectronBuilderInvocations(argv) {
+ const targets = [];
+ const sharedArgs = [];
+
+ for (const arg of argv) {
+ const target = PLATFORM_FLAGS.get(arg);
+ if (target) {
+ if (!targets.includes(target)) {
+ targets.push(target);
+ }
+ continue;
+ }
+ sharedArgs.push(arg);
+ }
+
+ if (targets.length === 0) {
+ return [{ args: sharedArgs }];
+ }
+
+ return targets.map((target) => ({
+ args: [
+ PLATFORM_ARGS[target],
+ ...sharedArgs,
+ ...(target === 'linux' ? LINUX_PACKAGE_NAME_OVERRIDES : []),
+ ],
+ }));
+}
+
+module.exports = {
+ buildElectronBuilderInvocations,
+};
diff --git a/scripts/electron-builder/dist.mjs b/scripts/electron-builder/dist.mjs
new file mode 100644
index 00000000..cc98ed61
--- /dev/null
+++ b/scripts/electron-builder/dist.mjs
@@ -0,0 +1,52 @@
+#!/usr/bin/env node
+import { spawn } from 'node:child_process';
+import { createRequire } from 'node:module';
+import { pathToFileURL } from 'node:url';
+
+const require = createRequire(import.meta.url);
+const { buildElectronBuilderInvocations } = require('./dist-invocations.cjs');
+
+export { buildElectronBuilderInvocations };
+
+async function runElectronBuilder(args) {
+ const cliPath = require.resolve('electron-builder/cli.js');
+ await new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, [cliPath, ...args], {
+ stdio: 'inherit',
+ env: process.env,
+ });
+
+ child.on('error', reject);
+ child.on('exit', (code, signal) => {
+ if (code === 0) {
+ resolve();
+ return;
+ }
+ reject(new Error(`electron-builder failed with ${signal ?? `exit code ${code}`}`));
+ });
+ });
+}
+
+async function main(argv) {
+ const invocations = buildElectronBuilderInvocations(argv);
+
+ if (process.env.ELECTRON_BUILDER_DIST_DRY_RUN === '1') {
+ console.log(
+ JSON.stringify(
+ invocations.map((invocation) => invocation.args),
+ null,
+ 2
+ )
+ );
+ return;
+ }
+
+ for (const invocation of invocations) {
+ await runElectronBuilder(invocation.args);
+ }
+}
+
+const entryPointUrl = process.argv[1] ? pathToFileURL(process.argv[1]).href : null;
+if (entryPointUrl === import.meta.url) {
+ await main(process.argv.slice(2));
+}
diff --git a/scripts/electron-builder/smokePackagedApp.cjs b/scripts/electron-builder/smokePackagedApp.cjs
index 68594830..9892a072 100644
--- a/scripts/electron-builder/smokePackagedApp.cjs
+++ b/scripts/electron-builder/smokePackagedApp.cjs
@@ -5,6 +5,7 @@ const { spawn } = require('node:child_process');
const STARTUP_TIMEOUT_MS = Number(process.env.PACKAGED_SMOKE_TIMEOUT_MS ?? 30_000);
const POST_STARTUP_STABLE_MS = Number(process.env.PACKAGED_SMOKE_STABLE_MS ?? 8_000);
+const SHUTDOWN_TIMEOUT_MS = Number(process.env.PACKAGED_SMOKE_SHUTDOWN_TIMEOUT_MS ?? 5_000);
const REQUIRED_LOG_MARKERS = ['renderer did-finish-load'];
const FAILURE_PATTERNS = [
/Cannot find module/i,
@@ -39,7 +40,10 @@ function findExecutable(bundlePath, platform) {
if (platform === 'win32') {
const executable = fs
.readdirSync(bundlePath)
- .find((entry) => entry.toLowerCase().endsWith('.exe') && !entry.toLowerCase().includes('uninstall'));
+ .find(
+ (entry) =>
+ entry.toLowerCase().endsWith('.exe') && !entry.toLowerCase().includes('uninstall')
+ );
if (!executable) fail(`No .exe found in ${bundlePath}`);
return path.join(bundlePath, executable);
}
@@ -66,6 +70,45 @@ function findExecutable(bundlePath, platform) {
fail(`Unsupported platform: ${platform}`);
}
+function waitForProcessClose(child, exitPromise, timeoutMs) {
+ if (child.exitCode !== null || child.signalCode !== null) {
+ return Promise.resolve(true);
+ }
+
+ let timeoutId;
+ const timeoutPromise = new Promise((resolve) => {
+ timeoutId = setTimeout(() => resolve(false), timeoutMs);
+ });
+ return Promise.race([exitPromise.then(() => true), timeoutPromise]).finally(() => {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ });
+}
+
+async function terminateChild(child, exitPromise, platform) {
+ if (child.exitCode !== null || child.signalCode !== null) {
+ return;
+ }
+
+ if (platform === 'win32' && child.pid) {
+ await new Promise((resolve) => {
+ const killer = spawn('taskkill.exe', ['/pid', String(child.pid), '/T', '/F'], {
+ stdio: 'ignore',
+ });
+ killer.once('error', resolve);
+ killer.once('close', resolve);
+ });
+ } else {
+ child.kill();
+ }
+
+ const closed = await waitForProcessClose(child, exitPromise, SHUTDOWN_TIMEOUT_MS);
+ if (!closed && child.exitCode === null && child.signalCode === null) {
+ throw new Error(`Timed out after ${SHUTDOWN_TIMEOUT_MS}ms waiting for packaged app to exit`);
+ }
+}
+
async function main() {
const [bundlePathArg, platform] = process.argv.slice(2);
if (!bundlePathArg || !platform) {
@@ -100,7 +143,7 @@ async function main() {
let startupSeenAt = null;
while (Date.now() < deadline) {
if (FAILURE_PATTERNS.some((pattern) => pattern.test(log))) {
- child.kill();
+ await terminateChild(child, exitPromise, platform);
fail('Detected startup failure pattern', log);
}
@@ -109,7 +152,7 @@ async function main() {
}
if (startupSeenAt !== null && Date.now() - startupSeenAt >= POST_STARTUP_STABLE_MS) {
- child.kill();
+ await terminateChild(child, exitPromise, platform);
console.log(`[smokePackagedApp] OK ${platform}: ${bundlePath}`);
return;
}
@@ -119,11 +162,14 @@ async function main() {
new Promise((resolve) => setTimeout(() => resolve(null), 250)),
]);
if (exit) {
- fail(`Packaged app exited before startup completed: code=${exit.code} signal=${exit.signal}`, log);
+ fail(
+ `Packaged app exited before startup completed: code=${exit.code} signal=${exit.signal}`,
+ log
+ );
}
}
- child.kill();
+ await terminateChild(child, exitPromise, platform);
fail(`Timed out after ${STARTUP_TIMEOUT_MS}ms waiting for packaged startup`, log);
}
diff --git a/scripts/electron-builder/verifyBundle.cjs b/scripts/electron-builder/verifyBundle.cjs
index 29ec81db..c4e68520 100644
--- a/scripts/electron-builder/verifyBundle.cjs
+++ b/scripts/electron-builder/verifyBundle.cjs
@@ -4,6 +4,18 @@ const afterPackModule = require('./afterPack.cjs');
const { validateNativeBinaries } = afterPackModule._internal;
+function isAllowedPostPackMismatch(mismatch, platform, arch) {
+ const relativePath = mismatch.path.split(path.sep).join('/');
+ return (
+ platform === 'win32' &&
+ arch === 'x64' &&
+ relativePath === 'resources/elevate.exe' &&
+ mismatch.format === 'pe' &&
+ mismatch.archs.length === 1 &&
+ mismatch.archs[0] === 'ia32'
+ );
+}
+
async function main() {
const [bundlePathArg, platform, arch] = process.argv.slice(2);
@@ -14,16 +26,22 @@ async function main() {
const bundlePath = path.resolve(bundlePathArg);
const mismatches = await validateNativeBinaries(bundlePath, platform, arch);
+ const blockingMismatches = mismatches.filter(
+ (mismatch) => !isAllowedPostPackMismatch(mismatch, platform, arch)
+ );
- if (mismatches.length === 0) {
- console.log(`[verifyBundle] OK ${platform}-${arch}: ${bundlePath}`);
+ if (blockingMismatches.length === 0) {
+ const allowedCount = mismatches.length - blockingMismatches.length;
+ const suffix =
+ allowedCount > 0 ? ` (${allowedCount} allowed post-pack helper mismatch ignored)` : '';
+ console.log(`[verifyBundle] OK ${platform}-${arch}: ${bundlePath}${suffix}`);
return;
}
console.error(
- `[verifyBundle] Found ${mismatches.length} incompatible native binaries in ${platform}-${arch}: ${bundlePath}`
+ `[verifyBundle] Found ${blockingMismatches.length} incompatible native binaries in ${platform}-${arch}: ${bundlePath}`
);
- for (const mismatch of mismatches.slice(0, 50)) {
+ for (const mismatch of blockingMismatches.slice(0, 50)) {
console.error(`- ${mismatch.path} [${mismatch.format}] -> ${mismatch.archs.join(', ')}`);
}
process.exit(1);
diff --git a/scripts/smoke/agent-attachments-smoke.mjs b/scripts/smoke/agent-attachments-smoke.mjs
index dc83b686..13f323d2 100644
--- a/scripts/smoke/agent-attachments-smoke.mjs
+++ b/scripts/smoke/agent-attachments-smoke.mjs
@@ -1,5 +1,6 @@
#!/usr/bin/env node
-import { spawn } from 'node:child_process';
+import { spawn, spawnSync } from 'node:child_process';
+import { existsSync } from 'node:fs';
import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
@@ -326,12 +327,93 @@ function parseArgs(argv) {
return { all, jsonPath, list, selected };
}
+function hasPathSeparator(value) {
+ return value.includes('/') || value.includes('\\');
+}
+
+function resolveWindowsSpawnBinary(binary) {
+ if (process.platform !== 'win32') {
+ return binary;
+ }
+
+ if (hasPathSeparator(binary)) {
+ if (!path.extname(binary) && existsSync(`${binary}.cmd`)) {
+ return `${binary}.cmd`;
+ }
+ return binary;
+ }
+
+ const whereResult = spawnSync('where.exe', [binary], {
+ encoding: 'utf8',
+ windowsHide: true,
+ });
+ if (whereResult.status !== 0 || !whereResult.stdout) {
+ return binary;
+ }
+
+ const candidates = whereResult.stdout
+ .split(/\r?\n/)
+ .map((candidate) => candidate.trim())
+ .filter(Boolean);
+ const extensionlessShim = candidates.find(
+ (candidate) => !path.extname(candidate) && existsSync(`${candidate}.cmd`)
+ );
+ if (extensionlessShim) {
+ return `${extensionlessShim}.cmd`;
+ }
+ return (
+ candidates.find((candidate) => /\.exe$/i.test(candidate)) ??
+ candidates.find((candidate) => /\.(?:cmd|bat)$/i.test(candidate)) ??
+ candidates[0] ??
+ binary
+ );
+}
+
+function quoteWindowsCmdArg(value) {
+ const text = String(value);
+ if (text.length === 0) {
+ return '""';
+ }
+ if (!/[ \t\r\n"&|<>^()%!]/.test(text)) {
+ return text;
+ }
+ return `"${text.replace(/%/g, '%%').replace(/(["^&|<>])/g, '^$1')}"`;
+}
+
+function buildSpawnInvocation(command) {
+ if (process.platform !== 'win32') {
+ return {
+ bin: command.bin,
+ args: command.args,
+ options: { windowsHide: true },
+ };
+ }
+
+ const resolvedBin = resolveWindowsSpawnBinary(command.bin);
+ if (/\.(?:cmd|bat)$/i.test(resolvedBin)) {
+ const commandLine = [resolvedBin, ...command.args].map(quoteWindowsCmdArg).join(' ');
+ return {
+ bin: process.env.ComSpec || 'cmd.exe',
+ args: ['/d', '/s', '/c', commandLine],
+ options: { windowsHide: true, windowsVerbatimArguments: true },
+ };
+ }
+
+ return {
+ bin: resolvedBin,
+ args: command.args,
+ options: { windowsHide: true },
+ };
+}
+
function runCommand(command) {
return new Promise((resolve) => {
- const child = spawn(command.bin, command.args, {
+ const spawnInvocation = buildSpawnInvocation(command);
+ const child = spawn(spawnInvocation.bin, spawnInvocation.args, {
stdio: ['pipe', 'pipe', 'pipe'],
env: process.env,
cwd: command.cwd,
+ ...spawnInvocation.options,
});
let stdout = '';
let stderr = '';
diff --git a/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts b/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts
index 28d8b880..0b3be39c 100644
--- a/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts
+++ b/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts
@@ -1,4 +1,5 @@
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
+import { normalizePathForComparison } from '@shared/utils/platformPath';
import type { DashboardRecentProject } from '@features/recent-projects/contracts';
@@ -34,7 +35,7 @@ function normalizeHistoryPath(projectPath: string): string | null {
normalizedPath = normalizedPath.slice(0, -1);
}
}
- return normalizedPath;
+ return normalizedPath ? normalizePathForComparison(normalizedPath) : null;
}
function foldHistoryPath(projectPath: string): string {
diff --git a/src/main/services/editor/FileSearchService.ts b/src/main/services/editor/FileSearchService.ts
index 52ce0927..691f3603 100644
--- a/src/main/services/editor/FileSearchService.ts
+++ b/src/main/services/editor/FileSearchService.ts
@@ -292,9 +292,7 @@ export class FileSearchService {
subdirs.push(fullPath);
} else if (entry.isFile()) {
if (IGNORED_FILES.has(entry.name)) continue;
- const relativePath = fullPath.startsWith(projectRoot)
- ? fullPath.slice(projectRoot.length + 1)
- : entry.name;
+ const relativePath = path.relative(projectRoot, fullPath).split(path.sep).join('/');
files.push({ path: fullPath, name: entry.name, relativePath });
}
}
diff --git a/src/renderer/components/chat/SessionContextPanel/DirectoryTree/buildDirectoryTree.ts b/src/renderer/components/chat/SessionContextPanel/DirectoryTree/buildDirectoryTree.ts
index 48e38d43..ec68af98 100644
--- a/src/renderer/components/chat/SessionContextPanel/DirectoryTree/buildDirectoryTree.ts
+++ b/src/renderer/components/chat/SessionContextPanel/DirectoryTree/buildDirectoryTree.ts
@@ -2,6 +2,8 @@
* Build a directory tree structure from CLAUDE.md injections.
*/
+import { getRelativePathWithinPrefix } from '@shared/utils/platformPath';
+
import type { TreeNode } from './types';
import type { ClaudeMdContextInjection } from '@renderer/types/contextInjection';
@@ -15,12 +17,9 @@ export function buildDirectoryTree(
const root: TreeNode = { name: '', path: '', isFile: false, children: new Map() };
for (const injection of injections) {
- let relativePath = injection.path;
- if (projectRoot && relativePath.startsWith(projectRoot)) {
- relativePath = relativePath.slice(projectRoot.length);
- if (relativePath.startsWith('/') || relativePath.startsWith('\\'))
- relativePath = relativePath.slice(1);
- }
+ const relativePath = projectRoot
+ ? (getRelativePathWithinPrefix(projectRoot, injection.path) ?? injection.path)
+ : injection.path;
const parts = relativePath.split(/[\\/]/);
let current = root;
diff --git a/src/renderer/components/chat/viewers/FileLink.tsx b/src/renderer/components/chat/viewers/FileLink.tsx
index 7ed49cf7..4b1017b8 100644
--- a/src/renderer/components/chat/viewers/FileLink.tsx
+++ b/src/renderer/components/chat/viewers/FileLink.tsx
@@ -57,21 +57,20 @@ export function resolveFileLinkPath(filePath: string, projectPath: string): stri
function normalizePathSegments(filePath: string): string {
const hasBackslash = filePath.includes('\\') && !filePath.includes('/');
const separator = hasBackslash ? '\\' : '/';
- const normalized = filePath.replace(/[/\\]+/g, separator);
let prefix = '';
- let body = normalized;
+ let body = filePath;
- const driveMatch = /^([A-Za-z]:)[\\/]/.exec(normalized);
+ const driveMatch = /^([A-Za-z]:)[\\/]/.exec(filePath);
if (driveMatch) {
prefix = `${driveMatch[1]}${separator}`;
- body = normalized.slice(prefix.length);
- } else if (normalized.startsWith(`${separator}${separator}`)) {
+ body = filePath.slice(driveMatch[0].length);
+ } else if (filePath.startsWith('\\\\') || filePath.startsWith('//')) {
prefix = `${separator}${separator}`;
- body = normalized.slice(2);
- } else if (normalized.startsWith(separator)) {
+ body = filePath.slice(2);
+ } else if (filePath.startsWith('/') || filePath.startsWith('\\')) {
prefix = separator;
- body = normalized.slice(1);
+ body = filePath.slice(1);
}
const segments: string[] = [];
diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx
index 90faaf38..cca878d4 100644
--- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx
+++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx
@@ -27,6 +27,7 @@ import {
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
+import { resolveFilePath } from '@renderer/store/utils/pathResolution';
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
import { nameColorSet } from '@renderer/utils/projectColor';
import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
@@ -318,9 +319,8 @@ function isAllowedElement(element: { tagName: string }): boolean {
}
/** Resolve a relative path to an absolute path given a base directory */
-function resolveRelativePath(relativeSrc: string, baseDir: string): string {
- const cleaned = relativeSrc.startsWith('./') ? relativeSrc.slice(2) : relativeSrc;
- return `${baseDir}/${cleaned}`;
+export function resolveRelativePath(relativeSrc: string, baseDir: string): string {
+ return resolveFilePath(baseDir, relativeSrc);
}
// =============================================================================
diff --git a/src/renderer/components/settings/sections/GeneralSection.tsx b/src/renderer/components/settings/sections/GeneralSection.tsx
index 0e653f52..dd0ef9a1 100644
--- a/src/renderer/components/settings/sections/GeneralSection.tsx
+++ b/src/renderer/components/settings/sections/GeneralSection.tsx
@@ -254,7 +254,9 @@ export const GeneralSection = ({
const resolvedClaudeRootPath = claudeRootInfo?.resolvedPath ?? '~/.claude';
const defaultClaudeRootPath = claudeRootInfo?.defaultPath ?? '~/.claude';
const isWindowsStyleDefaultPath =
- /^[a-zA-Z]:\\/.test(defaultClaudeRootPath) || defaultClaudeRootPath.startsWith('\\\\');
+ /^[a-zA-Z]:[/\\]/.test(defaultClaudeRootPath) ||
+ defaultClaudeRootPath.startsWith('\\\\') ||
+ defaultClaudeRootPath.startsWith('//');
const isElectron = useMemo(() => isElectronMode(), []);
diff --git a/src/renderer/components/team/editor/EditorContextMenu.tsx b/src/renderer/components/team/editor/EditorContextMenu.tsx
index 5a795ff9..befabb3f 100644
--- a/src/renderer/components/team/editor/EditorContextMenu.tsx
+++ b/src/renderer/components/team/editor/EditorContextMenu.tsx
@@ -9,7 +9,7 @@
import React, { useCallback, useRef, useState } from 'react';
import * as ContextMenu from '@radix-ui/react-context-menu';
-import { lastSeparatorIndex } from '@shared/utils/platformPath';
+import { getRelativePathWithinPrefix, lastSeparatorIndex } from '@shared/utils/platformPath';
import {
ClipboardCopy,
FilePlus,
@@ -87,6 +87,8 @@ export const EditorContextMenu = ({
? target.path
: target.path.substring(0, lastSeparatorIndex(target.path))
: null;
+ const targetRelativePath =
+ projectPath && target ? getRelativePathWithinPrefix(projectPath, target.path) : null;
return (
@@ -154,12 +156,11 @@ export const EditorContextMenu = ({
Copy Path
- {projectPath && target.path.startsWith(projectPath) && (
+ {targetRelativePath !== null && (
{
- const relative = target.path.slice(projectPath.length + 1);
- void navigator.clipboard.writeText(relative);
+ void navigator.clipboard.writeText(targetRelativePath);
}}
>
diff --git a/src/renderer/components/team/editor/SearchInFilesPanel.tsx b/src/renderer/components/team/editor/SearchInFilesPanel.tsx
index 4253a22d..81d57b58 100644
--- a/src/renderer/components/team/editor/SearchInFilesPanel.tsx
+++ b/src/renderer/components/team/editor/SearchInFilesPanel.tsx
@@ -10,7 +10,11 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
-import { getBasename, lastSeparatorIndex } from '@shared/utils/platformPath';
+import {
+ getBasename,
+ getRelativePathWithinPrefix,
+ lastSeparatorIndex,
+} from '@shared/utils/platformPath';
import { Loader2, Search, X } from 'lucide-react';
import { FileIcon } from './FileIcon';
@@ -146,7 +150,7 @@ export const SearchInFilesPanel = ({
const getRelativePath = useCallback(
(filePath: string) => {
- return filePath.startsWith(projectPath) ? filePath.slice(projectPath.length + 1) : filePath;
+ return getRelativePathWithinPrefix(projectPath, filePath) ?? filePath;
},
[projectPath]
);
diff --git a/src/renderer/hooks/useFileSuggestions.ts b/src/renderer/hooks/useFileSuggestions.ts
index 15b8541e..f5a8449c 100644
--- a/src/renderer/hooks/useFileSuggestions.ts
+++ b/src/renderer/hooks/useFileSuggestions.ts
@@ -13,6 +13,7 @@ import {
onQuickOpenCacheInvalidated,
setQuickOpenCache,
} from '@renderer/utils/quickOpenCache';
+import { joinPath, splitPath } from '@shared/utils/platformPath';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { QuickOpenFile } from '@shared/types/editor';
@@ -53,12 +54,12 @@ export function formatFileMentionPath(relativePath: string): string {
* Extracts unique directories from a list of file paths.
* Returns directories sorted by depth (shallower first), then alphabetically.
*/
-function extractDirectories(files: QuickOpenFile[], projectPath: string): DerivedFolder[] {
+export function extractDirectories(files: QuickOpenFile[], projectPath: string): DerivedFolder[] {
const dirSet = new Set();
for (const f of files) {
// Walk up the directory chain from each file's relative path
- const parts = f.relativePath.split('/');
+ const parts = splitPath(f.relativePath);
// Remove the file name — keep only directory segments
for (let i = 1; i < parts.length; i++) {
dirSet.add(parts.slice(0, i).join('/'));
@@ -67,19 +68,19 @@ function extractDirectories(files: QuickOpenFile[], projectPath: string): Derive
const folders: DerivedFolder[] = [];
for (const relDir of dirSet) {
- const segments = relDir.split('/');
+ const segments = splitPath(relDir);
const name = segments[segments.length - 1];
folders.push({
name,
relativePath: relDir + '/',
- absolutePath: projectPath + '/' + relDir,
+ absolutePath: joinPath(projectPath, ...splitPath(relDir)),
});
}
// Sort: shallower first, then alphabetically
folders.sort((a, b) => {
- const depthA = a.relativePath.split('/').length;
- const depthB = b.relativePath.split('/').length;
+ const depthA = splitPath(a.relativePath).length;
+ const depthB = splitPath(b.relativePath).length;
if (depthA !== depthB) return depthA - depthB;
return a.relativePath.localeCompare(b.relativePath);
});
@@ -94,13 +95,14 @@ function extractDirectories(files: QuickOpenFile[], projectPath: string): Derive
export function filterFileSuggestions(files: QuickOpenFile[], query: string): MentionSuggestion[] {
if (!query || files.length === 0) return [];
- const lower = query.toLowerCase();
+ const lower = query.replace(/\\/g, '/').toLowerCase();
const results: MentionSuggestion[] = [];
for (const f of files) {
if (results.length >= MAX_FILE_SUGGESTIONS) break;
- if (f.name.toLowerCase().includes(lower) || f.relativePath.toLowerCase().includes(lower)) {
+ const relativePathForMatch = f.relativePath.replace(/\\/g, '/').toLowerCase();
+ if (f.name.toLowerCase().includes(lower) || relativePathForMatch.includes(lower)) {
results.push({
id: `file:${f.path}`,
name: f.name,
@@ -127,14 +129,15 @@ export function filterFolderSuggestions(
if (!query || folders.length === 0) return [];
// Strip trailing slash from query for matching (e.g. "ui/" -> "ui")
- const cleanQuery = query.endsWith('/') ? query.slice(0, -1) : query;
- const lower = cleanQuery.toLowerCase();
+ const cleanQuery = query.endsWith('/') || query.endsWith('\\') ? query.slice(0, -1) : query;
+ const lower = cleanQuery.replace(/\\/g, '/').toLowerCase();
const results: MentionSuggestion[] = [];
for (const f of folders) {
if (results.length >= MAX_FOLDER_SUGGESTIONS) break;
- if (f.name.toLowerCase().includes(lower) || f.relativePath.toLowerCase().includes(lower)) {
+ const relativePathForMatch = f.relativePath.replace(/\\/g, '/').toLowerCase();
+ if (f.name.toLowerCase().includes(lower) || relativePathForMatch.includes(lower)) {
results.push({
id: `folder:${f.absolutePath}`,
name: f.name + '/',
diff --git a/src/renderer/store/utils/pathResolution.ts b/src/renderer/store/utils/pathResolution.ts
index 7d520af8..43b58f0f 100644
--- a/src/renderer/store/utils/pathResolution.ts
+++ b/src/renderer/store/utils/pathResolution.ts
@@ -2,6 +2,8 @@
* Path resolution utilities for the store.
*/
+import { stripTrailingSeparators } from '@shared/utils/platformPath';
+
/**
* Resolves a relative path against a base path, handling various path formats.
* Handles:
@@ -17,7 +19,7 @@ export function resolveFilePath(base: string, relativePath: string): string {
return relativePath;
}
- const cleanBase = trimTrailingSeparator(base);
+ const cleanBase = stripTrailingSeparators(base);
// Handle @ prefix (file mention marker) - strip it if present
let cleanRelative = relativePath;
@@ -32,21 +34,22 @@ export function resolveFilePath(base: string, relativePath: string): string {
}
// Handle ./ prefix (current directory)
- if (cleanRelative.startsWith('./')) {
+ if (cleanRelative.startsWith('./') || cleanRelative.startsWith('.\\')) {
cleanRelative = cleanRelative.slice(2);
}
// Handle ../ prefixes (parent directory)
const separator = cleanBase.includes('\\') ? '\\' : '/';
- const hasUnixRoot = cleanBase.startsWith('/');
- const hasUncRoot = cleanBase.startsWith('\\\\');
+ const hasUncRoot = cleanBase.startsWith('\\\\') || cleanBase.startsWith('//');
+ const hasUnixRoot = !hasUncRoot && cleanBase.startsWith('/');
+ const minRootParts = hasUncRoot ? 2 : 1;
const normalizedRelative = normalizeSeparators(cleanRelative, separator);
const baseParts = splitPath(cleanBase);
let remainingRelative = normalizedRelative;
while (remainingRelative.startsWith(`..${separator}`)) {
remainingRelative = remainingRelative.slice(3);
- if (baseParts.length > 1) {
+ if (baseParts.length > minRootParts) {
baseParts.pop();
}
}
@@ -56,8 +59,8 @@ export function resolveFilePath(base: string, relativePath: string): string {
if (hasUnixRoot && !normalizedBase.startsWith('/')) {
normalizedBase = `/${normalizedBase}`;
}
- if (hasUncRoot && !normalizedBase.startsWith('\\\\')) {
- normalizedBase = `\\\\${normalizedBase}`;
+ if (hasUncRoot && !normalizedBase.startsWith(`${separator}${separator}`)) {
+ normalizedBase = `${separator}${separator}${normalizedBase}`;
}
return remainingRelative ? `${normalizedBase}${separator}${remainingRelative}` : normalizedBase;
}
@@ -66,18 +69,6 @@ function isAbsolutePath(input: string): boolean {
return input.startsWith('/') || input.startsWith('\\\\') || /^[a-zA-Z]:[\\/]/.test(input);
}
-function trimTrailingSeparator(input: string): string {
- let end = input.length;
- while (end > 0) {
- const char = input[end - 1];
- if (char !== '/' && char !== '\\') {
- break;
- }
- end--;
- }
- return input.slice(0, end);
-}
-
function normalizeSeparators(input: string, separator: '/' | '\\'): string {
let output = '';
let prevWasSeparator = false;
diff --git a/src/renderer/utils/claudeMdTracker.ts b/src/renderer/utils/claudeMdTracker.ts
index 5510d570..ac045070 100644
--- a/src/renderer/utils/claudeMdTracker.ts
+++ b/src/renderer/utils/claudeMdTracker.ts
@@ -8,8 +8,11 @@
*/
import {
+ isPathPrefix,
lastSeparatorIndex,
+ normalizePathForComparison,
splitPath as splitPathCrossPlatform,
+ stripTrailingSeparators,
} from '@shared/utils/platformPath';
import { extractFileReferences } from './groupTransformer';
@@ -64,7 +67,14 @@ export function getDisplayName(path: string, _source: ClaudeMdSource): string {
* Check if a path is absolute (starts with /).
*/
function isAbsolutePath(path: string): boolean {
- return path.startsWith('/') || path.startsWith('\\\\') || /^[a-zA-Z]:[\\/]/.test(path);
+ return (
+ path.startsWith('/') ||
+ path.startsWith('~/') ||
+ path.startsWith('~\\') ||
+ path === '~' ||
+ path.startsWith('\\\\') ||
+ /^[a-zA-Z]:[\\/]/.test(path)
+ );
}
/**
@@ -82,7 +92,7 @@ function joinPaths(base: string, relative: string): string {
}
// Remove trailing slash from base if present
- const cleanBase = trimTrailingSeparator(base);
+ const cleanBase = stripTrailingSeparators(base);
// Handle @ prefix (file mention marker) - strip it if present
let cleanRelative = relative;
@@ -91,20 +101,21 @@ function joinPaths(base: string, relative: string): string {
}
// Handle ./ prefix (current directory)
- if (cleanRelative.startsWith('./')) {
+ if (cleanRelative.startsWith('./') || cleanRelative.startsWith('.\\')) {
cleanRelative = cleanRelative.slice(2);
}
// Handle ../ prefixes (parent directory)
const separator = cleanBase.includes('\\') ? '\\' : '/';
- const hasUnixRoot = cleanBase.startsWith('/');
- const hasUncRoot = cleanBase.startsWith('\\\\');
+ const hasUncRoot = cleanBase.startsWith('\\\\') || cleanBase.startsWith('//');
+ const hasUnixRoot = !hasUncRoot && cleanBase.startsWith('/');
+ const minRootParts = hasUncRoot ? 2 : 1;
const normalizedRelative = normalizeSeparators(cleanRelative, separator);
const baseParts = splitPath(cleanBase);
let remainingRelative = normalizedRelative;
while (remainingRelative.startsWith(`..${separator}`)) {
remainingRelative = remainingRelative.slice(3);
- if (baseParts.length > 1) {
+ if (baseParts.length > minRootParts) {
baseParts.pop();
}
}
@@ -114,24 +125,12 @@ function joinPaths(base: string, relative: string): string {
if (hasUnixRoot && !normalizedBase.startsWith('/')) {
normalizedBase = `/${normalizedBase}`;
}
- if (hasUncRoot && !normalizedBase.startsWith('\\\\')) {
- normalizedBase = `\\\\${normalizedBase}`;
+ if (hasUncRoot && !normalizedBase.startsWith(`${separator}${separator}`)) {
+ normalizedBase = `${separator}${separator}${normalizedBase}`;
}
return remainingRelative ? `${normalizedBase}${separator}${remainingRelative}` : normalizedBase;
}
-function trimTrailingSeparator(input: string): string {
- let end = input.length;
- while (end > 0) {
- const char = input[end - 1];
- if (char !== '/' && char !== '\\') {
- break;
- }
- end--;
- }
- return input.slice(0, end);
-}
-
function normalizeSeparators(input: string, separator: '/' | '\\'): string {
let output = '';
let prevWasSeparator = false;
@@ -158,7 +157,19 @@ function splitPath(input: string): string[] {
}
function normalizeForComparison(input: string): string {
- return input.replace(/\\/g, '/');
+ return normalizePathForComparison(input);
+}
+
+function createSeenPathSet(paths: string[]): Set {
+ return new Set(paths.map(normalizeForComparison));
+}
+
+function hasSeenPath(seenPaths: Set, path: string): boolean {
+ return seenPaths.has(normalizeForComparison(path));
+}
+
+function rememberPath(seenPaths: Set, path: string): void {
+ seenPaths.add(normalizeForComparison(path));
}
/**
@@ -183,11 +194,7 @@ export function getParentDirectory(dirPath: string): string | null {
* Check if dirPath is at or above stopPath in the directory tree.
*/
function isAtOrAbove(dirPath: string, stopPath: string): boolean {
- const normDir = normalizeForComparison(dirPath).replace(/\/$/, '');
- const normStop = normalizeForComparison(stopPath).replace(/\/$/, '');
-
- // dirPath is at or above stopPath if stopPath starts with dirPath
- return normStop === normDir || normStop.startsWith(normDir + '/');
+ return isPathPrefix(dirPath, stopPath);
}
// =============================================================================
@@ -485,7 +492,7 @@ function computeClaudeMdStats(params: ComputeClaudeMdStatsParams): ClaudeMdStats
} = params;
const newInjections: ClaudeMdInjection[] = [];
- const previousPaths = new Set(previousInjections.map((inj) => inj.path));
+ const previousPaths = createSeenPathSet(previousInjections.map((inj) => inj.path));
// For the first group, add global injections
// Use "ai-N" format for firstSeenInGroup to enable turn navigation in SessionClaudeMdPanel
@@ -493,9 +500,9 @@ function computeClaudeMdStats(params: ComputeClaudeMdStatsParams): ClaudeMdStats
if (isFirstGroup) {
const globalInjections = createGlobalInjections(projectRoot, turnGroupId, tokenData);
for (const injection of globalInjections) {
- if (!previousPaths.has(injection.path)) {
+ if (!hasSeenPath(previousPaths, injection.path)) {
newInjections.push(injection);
- previousPaths.add(injection.path);
+ rememberPath(previousPaths, injection.path);
}
}
}
@@ -526,7 +533,7 @@ function computeClaudeMdStats(params: ComputeClaudeMdStatsParams): ClaudeMdStats
for (const claudeMdPath of claudeMdPaths) {
// Skip if already seen
- if (previousPaths.has(claudeMdPath)) {
+ if (hasSeenPath(previousPaths, claudeMdPath)) {
continue;
}
@@ -546,7 +553,7 @@ function computeClaudeMdStats(params: ComputeClaudeMdStatsParams): ClaudeMdStats
// Create directory injection
const injection = createDirectoryInjection(claudeMdPath, turnGroupId);
newInjections.push(injection);
- previousPaths.add(claudeMdPath);
+ rememberPath(previousPaths, claudeMdPath);
}
}
diff --git a/src/renderer/utils/contextTracker.ts b/src/renderer/utils/contextTracker.ts
index 78eb4986..ec8ecd8e 100644
--- a/src/renderer/utils/contextTracker.ts
+++ b/src/renderer/utils/contextTracker.ts
@@ -9,6 +9,7 @@
* This builds on claudeMdTracker.ts and extends it to track all context sources.
*/
+import { normalizePathForComparison, stripTrailingSeparators } from '@shared/utils/platformPath';
import { estimateTokens } from '@shared/utils/tokenFormatting';
import { MAX_MENTIONED_FILE_TOKENS } from '../types/contextInjection';
@@ -474,7 +475,7 @@ function joinPaths(base: string, relative: string): string {
return relative;
}
- const cleanBase = trimTrailingSeparator(base);
+ const cleanBase = stripTrailingSeparators(base);
// Handle @ prefix (file mention marker) - strip it if present
let cleanRelative = relative;
@@ -483,20 +484,21 @@ function joinPaths(base: string, relative: string): string {
}
// Handle ./ prefix (current directory)
- if (cleanRelative.startsWith('./')) {
+ if (cleanRelative.startsWith('./') || cleanRelative.startsWith('.\\')) {
cleanRelative = cleanRelative.slice(2);
}
// Handle ../ prefixes (parent directory)
const separator = cleanBase.includes('\\') ? '\\' : '/';
- const hasUnixRoot = cleanBase.startsWith('/');
- const hasUncRoot = cleanBase.startsWith('\\\\');
+ const hasUncRoot = cleanBase.startsWith('\\\\') || cleanBase.startsWith('//');
+ const hasUnixRoot = !hasUncRoot && cleanBase.startsWith('/');
+ const minRootParts = hasUncRoot ? 2 : 1;
const normalizedRelative = normalizeSeparators(cleanRelative, separator);
const baseParts = splitPath(cleanBase);
let remainingRelative = normalizedRelative;
while (remainingRelative.startsWith(`..${separator}`)) {
remainingRelative = remainingRelative.slice(3);
- if (baseParts.length > 1) {
+ if (baseParts.length > minRootParts) {
baseParts.pop();
}
}
@@ -506,24 +508,12 @@ function joinPaths(base: string, relative: string): string {
if (hasUnixRoot && !normalizedBase.startsWith('/')) {
normalizedBase = `/${normalizedBase}`;
}
- if (hasUncRoot && !normalizedBase.startsWith('\\\\')) {
- normalizedBase = `\\\\${normalizedBase}`;
+ if (hasUncRoot && !normalizedBase.startsWith(`${separator}${separator}`)) {
+ normalizedBase = `${separator}${separator}${normalizedBase}`;
}
return remainingRelative ? `${normalizedBase}${separator}${remainingRelative}` : normalizedBase;
}
-function trimTrailingSeparator(input: string): string {
- let end = input.length;
- while (end > 0) {
- const char = input[end - 1];
- if (char !== '/' && char !== '\\') {
- break;
- }
- end--;
- }
- return input.slice(0, end);
-}
-
function normalizeSeparators(input: string, separator: '/' | '\\'): string {
let output = '';
let prevWasSeparator = false;
@@ -567,7 +557,50 @@ function splitPath(input: string): string[] {
}
function normalizeForComparison(input: string): string {
- return input.replace(/\\/g, '/');
+ return normalizePathForComparison(input);
+}
+
+function createSeenPathSet(paths: string[]): Set {
+ return new Set(paths.map(normalizeForComparison));
+}
+
+function hasSeenPath(seenPaths: Set, path: string): boolean {
+ return seenPaths.has(normalizeForComparison(path));
+}
+
+function rememberPath(seenPaths: Set, path: string): void {
+ seenPaths.add(normalizeForComparison(path));
+}
+
+function getRecordValueByPath(
+ record: Record | undefined,
+ path: string
+): T | undefined {
+ if (!record) return undefined;
+ const exact = record[path];
+ if (exact !== undefined) return exact;
+
+ const normalizedPath = normalizeForComparison(path);
+ for (const [key, value] of Object.entries(record)) {
+ if (normalizeForComparison(key) === normalizedPath) {
+ return value;
+ }
+ }
+ return undefined;
+}
+
+function getMapValueByPath(map: Map | undefined, path: string): T | undefined {
+ if (!map) return undefined;
+ const exact = map.get(path);
+ if (exact !== undefined) return exact;
+
+ const normalizedPath = normalizeForComparison(path);
+ for (const [key, value] of map.entries()) {
+ if (normalizeForComparison(key) === normalizedPath) {
+ return value;
+ }
+ }
+ return undefined;
}
/**
@@ -604,7 +637,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
} = params;
const newInjections: ContextInjection[] = [];
- const previousPaths = new Set(
+ const previousPaths = createSeenPathSet(
previousInjections
.filter(
(inj): inj is ClaudeMdContextInjection | MentionedFileInjection =>
@@ -620,9 +653,9 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
if (isFirstGroup) {
const globalInjections = createGlobalInjections(projectRoot, turnGroupId, claudeMdTokenData);
for (const injection of globalInjections) {
- if (!previousPaths.has(injection.path)) {
+ if (!hasSeenPath(previousPaths, injection.path)) {
newInjections.push(wrapClaudeMdInjection(injection));
- previousPaths.add(injection.path);
+ rememberPath(previousPaths, injection.path);
}
}
}
@@ -654,7 +687,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
for (const claudeMdPath of claudeMdPaths) {
// Skip if already seen
- if (previousPaths.has(claudeMdPath)) {
+ if (hasSeenPath(previousPaths, claudeMdPath)) {
continue;
}
@@ -674,7 +707,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
// Only include directory CLAUDE.md files that exist (validated via directoryTokenData)
// If directoryTokenData is provided and doesn't contain this path, the file doesn't exist
if (directoryTokenData) {
- const fileInfo = directoryTokenData[claudeMdPath];
+ const fileInfo = getRecordValueByPath(directoryTokenData, claudeMdPath);
if (!fileInfo || !fileInfo.exists || fileInfo.estimatedTokens <= 0) {
// File doesn't exist or has no content - skip it
continue;
@@ -683,12 +716,12 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
const injection = createDirectoryInjection(claudeMdPath, turnGroupId);
injection.estimatedTokens = fileInfo.estimatedTokens;
newInjections.push(wrapClaudeMdInjection(injection));
- previousPaths.add(claudeMdPath);
+ rememberPath(previousPaths, claudeMdPath);
} else {
// Fallback: if no directoryTokenData provided, create with default tokens (legacy behavior)
const injection = createDirectoryInjection(claudeMdPath, turnGroupId);
newInjections.push(wrapClaudeMdInjection(injection));
- previousPaths.add(claudeMdPath);
+ rememberPath(previousPaths, claudeMdPath);
}
}
}
@@ -704,12 +737,12 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
: joinPaths(projectRoot, fileRef.path);
// Skip if already seen
- if (previousPaths.has(absolutePath)) {
+ if (hasSeenPath(previousPaths, absolutePath)) {
continue;
}
// Check if we have token data for this file
- const fileInfo = mentionedFileTokenData?.get(absolutePath);
+ const fileInfo = getMapValueByPath(mentionedFileTokenData, absolutePath);
// Only include files that exist and are under the token limit
if (fileInfo && fileInfo.exists && fileInfo.estimatedTokens <= MAX_MENTIONED_FILE_TOKENS) {
@@ -723,7 +756,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
});
newInjections.push(mentionedFileInjection);
- previousPaths.add(absolutePath);
+ rememberPath(previousPaths, absolutePath);
}
}
}
@@ -736,11 +769,11 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
? fileRef.path
: joinPaths(projectRoot, fileRef.path);
- if (previousPaths.has(absolutePath)) {
+ if (hasSeenPath(previousPaths, absolutePath)) {
continue;
}
- const fileInfo = mentionedFileTokenData?.get(absolutePath);
+ const fileInfo = getMapValueByPath(mentionedFileTokenData, absolutePath);
if (fileInfo && fileInfo.exists && fileInfo.estimatedTokens <= MAX_MENTIONED_FILE_TOKENS) {
const mentionedFileInjection = createMentionedFileInjection({
@@ -753,7 +786,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
});
newInjections.push(mentionedFileInjection);
- previousPaths.add(absolutePath);
+ rememberPath(previousPaths, absolutePath);
}
}
diff --git a/src/renderer/utils/pathDisplay.ts b/src/renderer/utils/pathDisplay.ts
index ccdd6808..a9955671 100644
--- a/src/renderer/utils/pathDisplay.ts
+++ b/src/renderer/utils/pathDisplay.ts
@@ -9,7 +9,7 @@
* Also provides resolveAbsolutePath() for clipboard copy (~ → real home, relative → absolute).
*/
-import { splitPath } from '@shared/utils/platformPath';
+import { getRelativePathWithinPrefix, splitPath } from '@shared/utils/platformPath';
function isWindowsAbsolutePath(input: string): boolean {
return /^[A-Za-z]:[/\\]/.test(input) || input.startsWith('\\\\') || input.startsWith('//');
@@ -38,15 +38,9 @@ export function shortenDisplayPath(fullPath: string, projectRoot?: string, maxLe
// 1. Make relative to project root
if (projectRoot) {
- const root = projectRoot.replace(/[/\\]$/, '');
- const caseInsensitive = isWindowsAbsolutePath(p) || isWindowsAbsolutePath(root);
- const pathForCompare = caseInsensitive ? p.toLowerCase() : p;
- const rootForCompare = caseInsensitive ? root.toLowerCase() : root;
- if (
- pathForCompare.startsWith(rootForCompare + '/') ||
- pathForCompare.startsWith(rootForCompare + '\\')
- ) {
- p = p.slice(root.length + 1);
+ const relativePath = getRelativePathWithinPrefix(projectRoot, p);
+ if (relativePath) {
+ p = relativePath;
}
}
@@ -54,7 +48,7 @@ export function shortenDisplayPath(fullPath: string, projectRoot?: string, maxLe
p = p
.replace(/^\/Users\/[^/]+/, '~')
.replace(/^\/home\/[^/]+/, '~')
- .replace(/^[A-Z]:\\Users\\[^\\]+/i, '~');
+ .replace(/^[A-Z]:[/\\]Users[/\\][^/\\]+/i, '~');
// 3. If short enough, return as-is
if (p.length <= maxLength) return p;
@@ -84,7 +78,7 @@ function inferHomeDir(projectRoot: string): string | null {
const match =
/^(\/Users\/[^/]+)/.exec(projectRoot) ??
/^(\/home\/[^/]+)/.exec(projectRoot) ??
- /^([A-Z]:\\Users\\[^\\]+)/i.exec(projectRoot);
+ /^([A-Z]:[/\\]Users[/\\][^/\\]+)/i.exec(projectRoot);
return match?.[1] ?? null;
}
@@ -122,11 +116,7 @@ export function formatProjectPath(path: string): string {
}
function isWindowsUserPath(input: string): boolean {
- if (input.length < 10) return false;
- const drive = input.charCodeAt(0);
- const hasDriveLetter =
- ((drive >= 65 && drive <= 90) || (drive >= 97 && drive <= 122)) && input[1] === ':';
- return hasDriveLetter && input.slice(2, 9).toLowerCase() === '\\users\\';
+ return /^[A-Z]:[/\\]Users[/\\]/i.test(input);
}
export function resolveAbsolutePath(filePath: string, projectRoot?: string): string {
diff --git a/src/shared/utils/platformPath.ts b/src/shared/utils/platformPath.ts
index 82f0c0aa..03eacb2f 100644
--- a/src/shared/utils/platformPath.ts
+++ b/src/shared/utils/platformPath.ts
@@ -78,6 +78,7 @@ export function joinPath(base: string, ...segments: string[]): string {
export function isPathPrefix(prefix: string, fullPath: string): boolean {
const p = stripTrailingSeparators(normalizePathForComparison(prefix));
const f = stripTrailingSeparators(normalizePathForComparison(fullPath));
+ if (!p) return false;
if (f === p) return true;
// Root prefixes are special: p already ends with "/" ("/" or "c:/").
if (p === '/') return f.startsWith('/');
@@ -85,6 +86,26 @@ export function isPathPrefix(prefix: string, fullPath: string): boolean {
return f.startsWith(p + '/');
}
+/** Return fullPath relative to prefix when fullPath is inside prefix, preserving fullPath style. */
+export function getRelativePathWithinPrefix(prefix: string, fullPath: string): string | null {
+ const cleanPrefix = stripTrailingSeparators(prefix);
+ if (!isPathPrefix(cleanPrefix, fullPath)) {
+ return null;
+ }
+
+ const normalizedPrefix = stripTrailingSeparators(normalizePathForComparison(cleanPrefix));
+ const normalizedFullPath = stripTrailingSeparators(normalizePathForComparison(fullPath));
+ if (normalizedFullPath === normalizedPrefix) {
+ return '';
+ }
+
+ let relativePath = fullPath.slice(cleanPrefix.length);
+ while (relativePath.startsWith('/') || relativePath.startsWith('\\')) {
+ relativePath = relativePath.slice(1);
+ }
+ return relativePath;
+}
+
/** Get the last segment (filename) from a path. */
export function getBasename(filePath: string): string {
const parts = splitPath(filePath);
diff --git a/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts b/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts
index 147ae8b6..98ddb38f 100644
--- a/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts
+++ b/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts
@@ -129,6 +129,20 @@ describe('recentProjectOpenHistory', () => {
).toBe(0);
});
+ it('collapses Windows drive-case and separator variants', () => {
+ recordRecentProjectOpenPaths(['C:\\Work\\Repo'], 5_000);
+ recordRecentProjectOpenPaths(['c:/work/repo/'], 8_000);
+
+ expect(
+ getRecentProjectLastOpenedAt(
+ makeProject({
+ primaryPath: 'C:/WORK/REPO',
+ associatedPaths: ['C:/WORK/REPO'],
+ })
+ )
+ ).toBe(8_000);
+ });
+
it('does not record generated ephemeral project paths', () => {
recordRecentProjectOpenPaths(
['/private/var/folders/7b/cache/T/codex-agent-teams-appstyle-zudek6i9', '/workspace/opened'],
@@ -152,4 +166,9 @@ describe('recentProjectOpenHistory', () => {
)
).toBe(10_000);
});
+
+ it('ignores blank project paths without throwing', () => {
+ expect(() => recordRecentProjectOpenPaths([' '], 10_000)).not.toThrow();
+ expect(getRecentProjectLastOpenedAt(makeProject({ primaryPath: ' ', associatedPaths: [' '] }))).toBe(0);
+ });
});
diff --git a/test/main/build/electronBuilderAfterPack.test.ts b/test/main/build/electronBuilderAfterPack.test.ts
index 7b8428e4..8835dbfc 100644
--- a/test/main/build/electronBuilderAfterPack.test.ts
+++ b/test/main/build/electronBuilderAfterPack.test.ts
@@ -45,8 +45,8 @@ function createElfBuffer(arch: 'arm64' | 'x64'): Buffer {
return buffer;
}
-function createPortableExecutableBuffer(arch: 'arm64' | 'x64'): Buffer {
- const machine = arch === 'arm64' ? 0xaa64 : 0x8664;
+function createPortableExecutableBuffer(arch: 'arm64' | 'ia32' | 'x64'): Buffer {
+ const machine = arch === 'arm64' ? 0xaa64 : arch === 'ia32' ? 0x014c : 0x8664;
const buffer = Buffer.alloc(256);
buffer[0] = 0x4d;
buffer[1] = 0x5a;
@@ -224,4 +224,72 @@ describe('electron-builder afterPack', () => {
)
).toBe(false);
});
+
+ it('accepts a clean x64 Windows bundle with optional standalone helper binaries', async () => {
+ const tempDir = createTempDir();
+ tempDirs.push(tempDir);
+
+ writeFile(path.join(tempDir, 'Agent Teams UI.exe'), createPortableExecutableBuffer('x64'));
+ writeFile(
+ path.join(
+ tempDir,
+ 'resources',
+ 'app.asar.unpacked',
+ 'node_modules',
+ 'node-pty',
+ 'build',
+ 'Release',
+ 'pty.node'
+ ),
+ createPortableExecutableBuffer('x64')
+ );
+ const pageantPath = path.join(
+ tempDir,
+ 'resources',
+ 'app.asar.unpacked',
+ 'node_modules',
+ 'ssh2',
+ 'util',
+ 'pagent.exe'
+ );
+ const armConptyDir = path.join(
+ tempDir,
+ 'resources',
+ 'app.asar.unpacked',
+ 'node_modules',
+ 'node-pty',
+ 'third_party',
+ 'conpty',
+ '1.23.251008001',
+ 'win10-arm64'
+ );
+ writeFile(pageantPath, createPortableExecutableBuffer('ia32'));
+ writeFile(path.join(armConptyDir, 'conpty.dll'), createPortableExecutableBuffer('arm64'));
+ writeFile(path.join(armConptyDir, 'OpenConsole.exe'), createPortableExecutableBuffer('arm64'));
+
+ await afterPackModule({
+ appOutDir: tempDir,
+ electronPlatformName: 'win32',
+ arch: 1,
+ });
+
+ expect(fs.existsSync(pageantPath)).toBe(true);
+ expect(fs.existsSync(armConptyDir)).toBe(false);
+ });
+
+ it('still reports unrelated ia32 Windows binaries in an x64 bundle', async () => {
+ const tempDir = createTempDir();
+ tempDirs.push(tempDir);
+
+ const badBinaryPath = path.join(tempDir, 'resources', 'app.asar.unpacked', 'bad-helper.exe');
+ writeFile(badBinaryPath, createPortableExecutableBuffer('ia32'));
+
+ await expect(validateNativeBinaries(tempDir, 'win32', 'x64')).resolves.toEqual([
+ {
+ path: path.join('resources', 'app.asar.unpacked', 'bad-helper.exe'),
+ format: 'pe',
+ archs: ['ia32'],
+ },
+ ]);
+ });
});
diff --git a/test/main/build/electronBuilderDistScript.test.ts b/test/main/build/electronBuilderDistScript.test.ts
new file mode 100644
index 00000000..79ae4377
--- /dev/null
+++ b/test/main/build/electronBuilderDistScript.test.ts
@@ -0,0 +1,44 @@
+// @vitest-environment node
+import { describe, expect, it } from 'vitest';
+
+const { buildElectronBuilderInvocations } = require('../../../scripts/electron-builder/dist-invocations.cjs');
+
+describe('electron-builder dist wrapper', () => {
+ it('splits multi-platform builds so Linux-only package name overrides do not affect macOS or Windows', async () => {
+ expect(
+ buildElectronBuilderInvocations(['--mac', '--win', '--linux', '--publish', 'never'])
+ ).toEqual([
+ { args: ['--mac', '--publish', 'never'] },
+ { args: ['--win', '--publish', 'never'] },
+ {
+ args: [
+ '--linux',
+ '--publish',
+ 'never',
+ '--config.productName=Agent-Teams-UI',
+ '--config.linux.desktop.entry.Name=Agent Teams UI',
+ ],
+ },
+ ]);
+ });
+
+ it('adds the filesystem-safe package name override to Linux-only builds', async () => {
+ expect(buildElectronBuilderInvocations(['--linux', '--publish', 'never'])).toEqual([
+ {
+ args: [
+ '--linux',
+ '--publish',
+ 'never',
+ '--config.productName=Agent-Teams-UI',
+ '--config.linux.desktop.entry.Name=Agent Teams UI',
+ ],
+ },
+ ]);
+ });
+
+ it('leaves macOS arch-specific builds unchanged', async () => {
+ expect(buildElectronBuilderInvocations(['--mac', '--arm64', '--publish', 'never'])).toEqual([
+ { args: ['--mac', '--arm64', '--publish', 'never'] },
+ ]);
+ });
+});
diff --git a/test/main/services/editor/FileSearchService.test.ts b/test/main/services/editor/FileSearchService.test.ts
index f9fa4d45..956580d2 100644
--- a/test/main/services/editor/FileSearchService.test.ts
+++ b/test/main/services/editor/FileSearchService.test.ts
@@ -6,6 +6,7 @@ import * as path from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('fs/promises', () => ({
+ access: vi.fn(),
readdir: vi.fn(),
readFile: vi.fn(),
stat: vi.fn(),
@@ -36,6 +37,7 @@ describe('FileSearchService', () => {
beforeEach(() => {
vi.resetAllMocks();
+ vi.mocked(fs.access).mockRejectedValue(new Error('not a git repository') as never);
service = new FileSearchService();
});
@@ -173,4 +175,27 @@ describe('FileSearchService', () => {
expect(result.results[0].matches[1].column).toBe(4);
expect(result.results[0].matches[2].column).toBe(8);
});
+
+ it('returns slash-normalized relative paths for quick open on Windows', async () => {
+ vi.mocked(fs.readdir).mockImplementation(async (dirPath: unknown) => {
+ const normalized = path.normalize(String(dirPath));
+ if (normalized === path.normalize(PROJECT_ROOT)) {
+ return [{ name: 'src', isFile: () => false, isDirectory: () => true }] as never;
+ }
+ if (normalized === path.normalize(path.join(PROJECT_ROOT, 'src'))) {
+ return [{ name: 'app.ts', isFile: () => true, isDirectory: () => false }] as never;
+ }
+ return [] as never;
+ });
+
+ const files = await service.listFiles(PROJECT_ROOT);
+
+ expect(files).toEqual([
+ {
+ path: path.join(PROJECT_ROOT, 'src', 'app.ts'),
+ name: 'app.ts',
+ relativePath: 'src/app.ts',
+ },
+ ]);
+ });
});
diff --git a/test/renderer/components/chat/SessionContextPanel/buildDirectoryTree.test.ts b/test/renderer/components/chat/SessionContextPanel/buildDirectoryTree.test.ts
new file mode 100644
index 00000000..bd7e1205
--- /dev/null
+++ b/test/renderer/components/chat/SessionContextPanel/buildDirectoryTree.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildDirectoryTree } from '@renderer/components/chat/SessionContextPanel/DirectoryTree/buildDirectoryTree';
+
+import type { ClaudeMdContextInjection } from '@renderer/types/contextInjection';
+
+function injection(path: string): ClaudeMdContextInjection {
+ return {
+ id: path,
+ category: 'claude-md',
+ path,
+ source: 'directory',
+ displayName: 'CLAUDE.md',
+ isGlobal: false,
+ estimatedTokens: 12,
+ firstSeenInGroup: 'ai-0',
+ };
+}
+
+describe('buildDirectoryTree Windows paths', () => {
+ it('strips project root case-insensitively for Windows paths with mixed separators', () => {
+ const root = buildDirectoryTree(
+ [injection('c:\\Users\\Alice\\repo\\src\\CLAUDE.md')],
+ 'C:/Users/Alice/Repo'
+ );
+
+ expect(root.children.has('c:')).toBe(false);
+ expect(root.children.get('src')?.children.get('CLAUDE.md')?.path).toBe(
+ 'c:\\Users\\Alice\\repo\\src\\CLAUDE.md'
+ );
+ });
+
+ it('does not strip sibling paths that only share a prefix', () => {
+ const root = buildDirectoryTree(
+ [injection('C:\\Users\\Alice\\Repo2\\CLAUDE.md')],
+ 'C:\\Users\\Alice\\Repo'
+ );
+
+ expect(root.children.get('C:')?.children.get('Users')).toBeDefined();
+ });
+
+ it('falls back to the injection path when project root is empty', () => {
+ const root = buildDirectoryTree([injection('C:\\Users\\Alice\\Repo\\CLAUDE.md')], '');
+
+ expect(root.children.get('C:')?.children.get('Users')).toBeDefined();
+ });
+});
diff --git a/test/renderer/components/chat/viewers/MarkdownViewer.test.tsx b/test/renderer/components/chat/viewers/MarkdownViewer.test.tsx
index 2ed11137..42c8218d 100644
--- a/test/renderer/components/chat/viewers/MarkdownViewer.test.tsx
+++ b/test/renderer/components/chat/viewers/MarkdownViewer.test.tsx
@@ -40,7 +40,10 @@ vi.mock('@renderer/components/chat/viewers/FileLink', () => ({
isRelativeUrl: () => false,
}));
-import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
+import {
+ MarkdownViewer,
+ resolveRelativePath,
+} from '@renderer/components/chat/viewers/MarkdownViewer';
describe('MarkdownViewer code blocks', () => {
afterEach(() => {
@@ -89,3 +92,23 @@ describe('MarkdownViewer code blocks', () => {
});
});
});
+
+describe('MarkdownViewer local image path resolution', () => {
+ it('resolves Windows relative image paths against a Windows base directory', () => {
+ expect(resolveRelativePath('.\\images\\plot.png', 'C:\\Repo\\docs')).toBe(
+ 'C:\\Repo\\docs\\images\\plot.png'
+ );
+ });
+
+ it('preserves absolute Windows image paths', () => {
+ expect(resolveRelativePath('C:\\Screens\\plot.png', 'C:\\Repo\\docs')).toBe(
+ 'C:\\Screens\\plot.png'
+ );
+ });
+
+ it('resolves Windows UNC parent image paths without escaping the share root', () => {
+ expect(resolveRelativePath('..\\assets\\plot.png', '\\\\server\\share\\repo\\docs')).toBe(
+ '\\\\server\\share\\repo\\assets\\plot.png'
+ );
+ });
+});
diff --git a/test/renderer/components/fileLink.test.ts b/test/renderer/components/fileLink.test.ts
index 851b3bd2..94edc29a 100644
--- a/test/renderer/components/fileLink.test.ts
+++ b/test/renderer/components/fileLink.test.ts
@@ -121,4 +121,19 @@ describe('resolveFileLinkPath', () => {
resolveFileLinkPath('/Users/belief/dev/projects/your_posts/docs/roadmap.md', PROJECT_PATH)
).toBe('/Users/belief/dev/projects/your_posts/docs/roadmap.md');
});
+
+ it('preserves Windows UNC roots when normalizing path segments', () => {
+ expect(resolveFileLinkPath('\\\\server\\share\\repo\\..\\docs\\roadmap.md', PROJECT_PATH)).toBe(
+ '\\\\server\\share\\docs\\roadmap.md'
+ );
+ expect(resolveFileLinkPath('//server/share/repo/../docs/roadmap.md', PROJECT_PATH)).toBe(
+ '//server/share/docs/roadmap.md'
+ );
+ });
+
+ it('preserves Windows drive roots when normalizing dot segments', () => {
+ expect(resolveFileLinkPath('C:\\repo\\src\\..\\README.md', PROJECT_PATH)).toBe(
+ 'C:\\repo\\README.md'
+ );
+ });
});
diff --git a/test/renderer/hooks/useFileSuggestions.test.ts b/test/renderer/hooks/useFileSuggestions.test.ts
index 1f916937..1b9744cf 100644
--- a/test/renderer/hooks/useFileSuggestions.test.ts
+++ b/test/renderer/hooks/useFileSuggestions.test.ts
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';
-import { filterFileSuggestions, formatFileMentionPath } from '@renderer/hooks/useFileSuggestions';
+import {
+ extractDirectories,
+ filterFileSuggestions,
+ filterFolderSuggestions,
+ formatFileMentionPath,
+} from '@renderer/hooks/useFileSuggestions';
import type { QuickOpenFile } from '@shared/types/editor';
@@ -91,14 +96,42 @@ describe('filterFileSuggestions', () => {
expect(results.map((r) => r.name)).toEqual(['auth.ts', 'database.ts']);
});
+ it('matches Windows backslash queries against normalized relative paths', () => {
+ const results = filterFileSuggestions(FILES, 'services\\auth');
+ expect(results).toHaveLength(1);
+ expect(results[0].relativePath).toBe('src/services/auth.ts');
+ });
+
it('returns results in file list order', () => {
const results = filterFileSuggestions(FILES, '.ts');
expect(results[0].name).toBe('index.ts');
});
it('quotes inserted paths that contain spaces', () => {
- expect(formatFileMentionPath('src/My Component/App.tsx')).toBe(
- '"src/My Component/App.tsx"'
- );
+ expect(formatFileMentionPath('src/My Component/App.tsx')).toBe('"src/My Component/App.tsx"');
+ });
+});
+
+describe('folder suggestions', () => {
+ it('derives stable folders from Windows relative paths', () => {
+ const folders = extractDirectories(
+ [
+ {
+ name: 'auth.ts',
+ relativePath: 'src\\services\\auth.ts',
+ path: 'C:\\Repo\\src\\services\\auth.ts',
+ },
+ ],
+ 'C:\\Repo'
+ );
+
+ expect(folders.map((f) => [f.name, f.relativePath, f.absolutePath])).toEqual([
+ ['src', 'src/', 'C:\\Repo\\src'],
+ ['services', 'src/services/', 'C:\\Repo\\src\\services'],
+ ]);
+
+ const suggestions = filterFolderSuggestions(folders, 'src\\services\\');
+ expect(suggestions).toHaveLength(1);
+ expect(suggestions[0].insertText).toBe('src/services/');
});
});
diff --git a/test/renderer/store/pathResolution.test.ts b/test/renderer/store/pathResolution.test.ts
index 19de8fad..218390dc 100644
--- a/test/renderer/store/pathResolution.test.ts
+++ b/test/renderer/store/pathResolution.test.ts
@@ -13,6 +13,7 @@ describe('resolveFilePath', () => {
it('resolves dot-prefixed relative paths', () => {
expect(resolveFilePath('/repo', './src/app.ts')).toBe('/repo/src/app.ts');
+ expect(resolveFilePath('C:\\repo', '.\\src\\app.ts')).toBe('C:\\repo\\src\\app.ts');
});
it('resolves parent relative paths on unix', () => {
@@ -27,6 +28,11 @@ describe('resolveFilePath', () => {
);
});
+ it('preserves Windows drive-root separators when resolving child paths', () => {
+ expect(resolveFilePath('C:\\', 'src\\app.ts')).toBe('C:\\src\\app.ts');
+ expect(resolveFilePath('C:/', 'src/app.ts')).toBe('C:/src/app.ts');
+ });
+
it('passes through tilde paths as-is', () => {
expect(resolveFilePath('/repo', '~/some/directory')).toBe('~/some/directory');
});
@@ -45,6 +51,24 @@ describe('resolveFilePath', () => {
);
});
+ it('resolves relative paths under Windows UNC roots', () => {
+ expect(resolveFilePath('\\\\server\\share\\repo', 'src\\index.ts')).toBe(
+ '\\\\server\\share\\repo\\src\\index.ts'
+ );
+ expect(resolveFilePath('//server/share/repo', 'src/index.ts')).toBe(
+ '//server/share/repo/src/index.ts'
+ );
+ });
+
+ it('does not resolve parent paths above a Windows UNC share root', () => {
+ expect(resolveFilePath('\\\\server\\share', '..\\outside\\file.ts')).toBe(
+ '\\\\server\\share\\outside\\file.ts'
+ );
+ expect(resolveFilePath('//server/share', '../outside/file.ts')).toBe(
+ '//server/share/outside/file.ts'
+ );
+ });
+
it('does not treat tilde in the middle as special', () => {
expect(resolveFilePath('/repo', 'foo~/bar')).toBe('/repo/foo~/bar');
});
diff --git a/test/renderer/utils/claudeMdTracker.test.ts b/test/renderer/utils/claudeMdTracker.test.ts
index 6e23567e..32f2199d 100644
--- a/test/renderer/utils/claudeMdTracker.test.ts
+++ b/test/renderer/utils/claudeMdTracker.test.ts
@@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest';
import {
detectClaudeMdFromFilePath,
+ extractUserMentionPaths,
getDirectory,
getParentDirectory,
+ processSessionClaudeMd,
} from '@renderer/utils/claudeMdTracker';
describe('claudeMdTracker path helpers', () => {
@@ -67,6 +69,13 @@ describe('claudeMdTracker path helpers', () => {
expect(result).toHaveLength(2);
});
+ it('detects CLAUDE.md files for Windows paths with drive-case and separator differences', () => {
+ const result = detectClaudeMdFromFilePath('c:\\Repo\\src\\file.ts', 'C:/repo');
+ expect(result).toContain('c:\\Repo\\src\\CLAUDE.md');
+ expect(result).toContain('c:\\Repo\\CLAUDE.md');
+ expect(result).toHaveLength(2);
+ });
+
it('uses correct separator for generated paths', () => {
const unixResult = detectClaudeMdFromFilePath('/repo/src/file.ts', '/repo');
for (const p of unixResult) {
@@ -93,3 +102,97 @@ describe('claudeMdTracker path helpers', () => {
});
});
});
+
+describe('processSessionClaudeMd Windows paths', () => {
+ function aiReadGroup(id: string, turnIndex: number, filePath: string) {
+ return {
+ id,
+ turnIndex,
+ startTime: new Date(0),
+ endTime: new Date(0),
+ durationMs: 0,
+ steps: [
+ {
+ type: 'tool_call',
+ content: {
+ toolName: 'Read',
+ toolInput: { file_path: filePath },
+ },
+ },
+ ],
+ tokens: { input: 1000, output: 0, cached: 0 },
+ summary: {
+ toolCallCount: 1,
+ outputMessageCount: 0,
+ subagentCount: 0,
+ totalDurationMs: 0,
+ totalTokens: 1000,
+ outputTokens: 0,
+ cachedTokens: 0,
+ },
+ status: 'complete',
+ processes: [],
+ chunkId: id,
+ metrics: {},
+ responses: [],
+ } as any;
+ }
+
+ it('dedupes directory CLAUDE.md paths across Windows case and separator differences', () => {
+ const stats = processSessionClaudeMd(
+ [
+ { type: 'ai', group: aiReadGroup('ai-0', 0, 'C:\\Repo\\src\\file.ts') },
+ { type: 'ai', group: aiReadGroup('ai-1', 1, 'c:/repo/src/other.ts') },
+ ],
+ 'C:\\Repo'
+ );
+
+ const firstDirectories = stats
+ .get('ai-0')!
+ .newInjections.filter((injection) => injection.source === 'directory')
+ .map((injection) => injection.path);
+ const secondDirectories = stats
+ .get('ai-1')!
+ .newInjections.filter((injection) => injection.source === 'directory');
+
+ expect(firstDirectories).toEqual(['C:\\Repo\\src\\CLAUDE.md']);
+ expect(secondDirectories).toEqual([]);
+ });
+});
+
+describe('extractUserMentionPaths Windows paths', () => {
+ function userGroupWithPath(path: string) {
+ return {
+ content: {
+ fileReferences: [{ path, raw: `@${path}` }],
+ },
+ } as any;
+ }
+
+ it('resolves Windows current-directory mentions with backslash separators', () => {
+ expect(extractUserMentionPaths(userGroupWithPath('.\\src\\app.ts'), 'C:\\Repo')).toEqual([
+ 'C:\\Repo\\src\\app.ts',
+ ]);
+ });
+
+ it('preserves Windows drive-root separators for relative mentions', () => {
+ expect(extractUserMentionPaths(userGroupWithPath('src\\app.ts'), 'C:\\')).toEqual([
+ 'C:\\src\\app.ts',
+ ]);
+ });
+
+ it('resolves relative mentions under UNC roots without escaping the share root', () => {
+ expect(
+ extractUserMentionPaths(userGroupWithPath('../outside/file.ts'), '//server/share')
+ ).toEqual(['//server/share/outside/file.ts']);
+ expect(
+ extractUserMentionPaths(userGroupWithPath('..\\outside\\file.ts'), '\\\\server\\share')
+ ).toEqual(['\\\\server\\share\\outside\\file.ts']);
+ });
+
+ it('leaves home-relative mentions untouched', () => {
+ expect(extractUserMentionPaths(userGroupWithPath('~\\.claude\\CLAUDE.md'), 'C:\\Repo')).toEqual(
+ ['~\\.claude\\CLAUDE.md']
+ );
+ });
+});
diff --git a/test/renderer/utils/contextTracker.test.ts b/test/renderer/utils/contextTracker.test.ts
new file mode 100644
index 00000000..ae2a04c6
--- /dev/null
+++ b/test/renderer/utils/contextTracker.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from 'vitest';
+
+import { processSessionContextWithPhases } from '@renderer/utils/contextTracker';
+
+function aiReadGroup(id: string, turnIndex: number, filePath: string) {
+ return {
+ id,
+ turnIndex,
+ startTime: new Date(0),
+ endTime: new Date(0),
+ durationMs: 0,
+ steps: [
+ {
+ type: 'tool_call',
+ content: {
+ toolName: 'Read',
+ toolInput: { file_path: filePath },
+ },
+ },
+ ],
+ tokens: { input: 1000, output: 0, cached: 0 },
+ summary: {
+ toolCallCount: 1,
+ outputMessageCount: 0,
+ subagentCount: 0,
+ totalDurationMs: 0,
+ totalTokens: 1000,
+ outputTokens: 0,
+ cachedTokens: 0,
+ },
+ status: 'complete',
+ processes: [],
+ chunkId: id,
+ metrics: {},
+ responses: [],
+ linkedTools: new Map(),
+ displayItems: [],
+ } as any;
+}
+
+describe('processSessionContextWithPhases Windows paths', () => {
+ it('matches validated directory CLAUDE.md data across drive-case and separator differences', () => {
+ const { statsMap } = processSessionContextWithPhases(
+ [{ type: 'ai', group: aiReadGroup('ai-0', 0, 'c:/repo/src/file.ts') }],
+ 'C:\\Repo',
+ undefined,
+ undefined,
+ {
+ 'C:\\Repo\\src\\CLAUDE.md': {
+ path: 'C:\\Repo\\src\\CLAUDE.md',
+ exists: true,
+ charCount: 492,
+ estimatedTokens: 123,
+ },
+ }
+ );
+
+ const directoryInjection = statsMap
+ .get('ai-0')!
+ .newInjections.find(
+ (injection) => injection.category === 'claude-md' && injection.source === 'directory'
+ );
+
+ expect(directoryInjection?.estimatedTokens).toBe(123);
+ });
+});
diff --git a/test/renderer/utils/pathDisplay.test.ts b/test/renderer/utils/pathDisplay.test.ts
index 20c5976b..f476bf16 100644
--- a/test/renderer/utils/pathDisplay.test.ts
+++ b/test/renderer/utils/pathDisplay.test.ts
@@ -8,25 +8,44 @@ import {
describe('pathDisplay Windows paths', () => {
it('treats lowercase drive paths as absolute', () => {
- expect(resolveAbsolutePath('c:\\Users\\Alice\\repo\\src\\app.ts', 'C:\\Users\\Alice\\repo')).toBe(
- 'c:\\Users\\Alice\\repo\\src\\app.ts'
- );
+ expect(
+ resolveAbsolutePath('c:\\Users\\Alice\\repo\\src\\app.ts', 'C:\\Users\\Alice\\repo')
+ ).toBe('c:\\Users\\Alice\\repo\\src\\app.ts');
});
it('shortens project-root relative paths case-insensitively on Windows', () => {
- expect(shortenDisplayPath('c:\\Users\\Alice\\repo\\src\\app.ts', 'C:\\Users\\Alice\\Repo')).toBe(
+ expect(
+ shortenDisplayPath('c:\\Users\\Alice\\repo\\src\\app.ts', 'C:\\Users\\Alice\\Repo')
+ ).toBe('src\\app.ts');
+ });
+
+ it('shortens mixed-separator Windows paths without treating siblings as children', () => {
+ expect(shortenDisplayPath('c:\\Users\\Alice\\Repo\\src\\app.ts', 'C:/Users/Alice/repo')).toBe(
'src\\app.ts'
);
+ expect(
+ shortenDisplayPath('C:\\Users\\Alice\\Repo2\\src\\app.ts', 'C:\\Users\\Alice\\Repo')
+ ).toBe('~\\Repo2\\src\\app.ts');
});
it('formats lowercase Windows user paths with a home marker', () => {
expect(formatProjectPath('c:\\users\\Alice\\repo')).toBe('~/repo');
+ expect(formatProjectPath('C:/Users/Alice/repo')).toBe('~/repo');
});
it('resolves home paths from lowercase Windows user roots', () => {
expect(resolveAbsolutePath('~/repo/src/app.ts', 'c:\\users\\Alice\\workspace')).toBe(
'c:\\users\\Alice\\repo\\src\\app.ts'
);
+ expect(resolveAbsolutePath('~/repo/src/app.ts', 'C:/Users/Alice/workspace')).toBe(
+ 'C:/Users/Alice/repo/src/app.ts'
+ );
+ });
+
+ it('shortens forward-slash Windows user paths with a home marker', () => {
+ expect(shortenDisplayPath('C:/Users/Alice/repo/src/app.ts', undefined, 80)).toBe(
+ '~/repo/src/app.ts'
+ );
});
it('resolves relative paths using the project root separator', () => {
diff --git a/test/shared/utils/platformPath.test.ts b/test/shared/utils/platformPath.test.ts
new file mode 100644
index 00000000..19d168cc
--- /dev/null
+++ b/test/shared/utils/platformPath.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest';
+
+import { getRelativePathWithinPrefix, isPathPrefix } from '../../../src/shared/utils/platformPath';
+
+describe('platformPath Windows containment', () => {
+ it('matches Windows drive paths case-insensitively and preserves child path style', () => {
+ expect(isPathPrefix('C:/Users/Alice/Repo', 'c:\\Users\\Alice\\repo\\src\\app.ts')).toBe(true);
+ expect(
+ getRelativePathWithinPrefix('C:/Users/Alice/Repo', 'c:\\Users\\Alice\\repo\\src\\app.ts')
+ ).toBe('src\\app.ts');
+ });
+
+ it('matches UNC paths with mixed separators', () => {
+ expect(
+ getRelativePathWithinPrefix('\\\\server\\share\\Repo', '//server/share/repo/src/app.ts')
+ ).toBe('src/app.ts');
+ });
+
+ it('rejects sibling paths that only share the same text prefix', () => {
+ expect(
+ getRelativePathWithinPrefix('C:\\Users\\Alice\\Repo', 'C:\\Users\\Alice\\Repo2\\x.ts')
+ ).toBe(null);
+ });
+
+ it('keeps POSIX paths case-sensitive', () => {
+ expect(getRelativePathWithinPrefix('/Users/Alice/Repo', '/Users/Alice/Repo/src/app.ts')).toBe(
+ 'src/app.ts'
+ );
+ expect(getRelativePathWithinPrefix('/Users/Alice/Repo', '/Users/Alice/repo/src/app.ts')).toBe(
+ null
+ );
+ });
+
+ it('does not treat an empty prefix as the root of absolute paths', () => {
+ expect(isPathPrefix('', '/Users/Alice/Repo/src/app.ts')).toBe(false);
+ expect(getRelativePathWithinPrefix('', '/Users/Alice/Repo/src/app.ts')).toBe(null);
+ });
+});
From 1cd77fbdd1f8750545745ba1976f54777d010de1 Mon Sep 17 00:00:00 2001
From: 777genius
Date: Sat, 16 May 2026 12:35:06 +0300
Subject: [PATCH 5/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 3552e423d9d89563e93423e8f8c53040aa46aa34 Mon Sep 17 00:00:00 2001
From: 777genius
Date: Sat, 16 May 2026 12:37:17 +0300
Subject: [PATCH 6/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.
diff --git a/package.json b/package.json
index 7eb4d707..76167bab 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 (
{card.activeTeams && card.activeTeams.length > 0 && (
@@ -32,9 +39,9 @@ export const RecentProjectCard = ({
-
@@ -42,6 +49,16 @@ export const RecentProjectCard = ({
{card.name}
+ {isDeleted && (
+
+
+
+ Deleted
+
+
+ Project folder no longer exists
+
+ )}
{card.pathSummary && (
@@ -91,21 +108,34 @@ export const RecentProjectCard = ({
tabIndex={0}
onClick={(event) => {
event.stopPropagation();
+ if (isDeleted) {
+ return;
+ }
onOpenPath();
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
+ if (isDeleted) {
+ return;
+ }
onOpenPath();
}
}}
- className="shrink-0 cursor-pointer rounded p-0.5 transition-colors hover:bg-white/5 hover:text-text-secondary"
+ className={cn(
+ 'shrink-0 rounded p-0.5 transition-colors',
+ isDeleted
+ ? 'cursor-not-allowed text-red-300/70'
+ : 'cursor-pointer hover:bg-white/5 hover:text-text-secondary'
+ )}
>
-
Open
+
+ {isDeleted ? 'Project folder no longer exists' : 'Open'}
+
diff --git a/src/main/index.ts b/src/main/index.ts
index a8474c66..4e8069e0 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -353,6 +353,11 @@ async function createOpenCodeRuntimeAdapterRegistry(
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
bridgeEnv.CLAUDE_TEAM_APP_INSTANCE_ID = openCodeManagedHostInstanceId;
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
+ const useHttpMcpBridge = bridgeEnv.CLAUDE_TEAM_OPENCODE_MCP_HTTP === '1';
+ if (!useHttpMcpBridge) {
+ // The OpenCode bridge direct tools/list proof currently requires a local MCP command.
+ delete bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL;
+ }
const applyMcpLaunchSpecEnv = async (
targetEnv: NodeJS.ProcessEnv,
options: { emitProgress?: boolean } = {}
@@ -408,17 +413,19 @@ async function createOpenCodeRuntimeAdapterRegistry(
}`
);
}
- try {
- reportProgress('runtime-mcp-http', 'Starting Agent Teams MCP server...');
- const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted();
- bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
- reportProgress('runtime-mcp-http-ready', 'Agent Teams MCP server is ready...');
- } catch (error) {
- logger.warn(
- `[OpenCode] Runtime adapter bridge MCP HTTP server unavailable: ${
- error instanceof Error ? error.message : String(error)
- }`
- );
+ if (useHttpMcpBridge) {
+ try {
+ reportProgress('runtime-mcp-http', 'Starting Agent Teams MCP server...');
+ const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted();
+ bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
+ reportProgress('runtime-mcp-http-ready', 'Agent Teams MCP server is ready...');
+ } catch (error) {
+ logger.warn(
+ `[OpenCode] Runtime adapter bridge MCP HTTP server unavailable: ${
+ error instanceof Error ? error.message : String(error)
+ }`
+ );
+ }
}
if (!bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) {
await applyMcpLaunchSpecEnv(bridgeEnv, { emitProgress: true });
@@ -427,7 +434,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
reportProgress('runtime-bridge', 'Preparing OpenCode bridge...');
const resolveBridgeCommandEnv = async (): Promise => {
const nextEnv = { ...bridgeEnv };
- if (!bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) {
+ if (!useHttpMcpBridge || !bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) {
return nextEnv;
}
try {
diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts
index 784aa15e..793f57a5 100644
--- a/src/main/services/discovery/ProjectScanner.ts
+++ b/src/main/services/discovery/ProjectScanner.ts
@@ -30,6 +30,7 @@ import {
import {
type PaginatedSessionsResult,
type Project,
+ type ProjectFilesystemState,
type RepositoryGroup,
type SearchSessionsResult,
type Session,
@@ -82,6 +83,21 @@ const SEARCH_PROJECT_CACHE_TTL_MS = 30_000;
// for lookups and navigation; a small cap preserves that behavior without huge payloads.
const MAX_SESSION_IDS_EXPORTED = 200;
+async function resolveProjectFilesystemState(
+ fsProvider: FileSystemProvider,
+ projectPath: string
+): Promise {
+ if (!projectPath.trim()) {
+ return 'deleted';
+ }
+
+ try {
+ return (await fsProvider.exists(projectPath)) ? 'available' : 'deleted';
+ } catch {
+ return 'deleted';
+ }
+}
+
export interface ProjectScannerOptions {
/**
* Directory for the persisted session-list metadata index.
@@ -340,6 +356,7 @@ export class ProjectScanner {
totalSessions,
createdAt: project.createdAt,
mostRecentSession: project.mostRecentSession,
+ filesystemState: project.filesystemState,
},
],
name: project.name,
@@ -360,6 +377,7 @@ export class ProjectScanner {
const encodedId = customPath.replace(/[/\\]/g, '-');
const folderName = customPath.split(/[/\\]/).filter(Boolean).pop() ?? customPath;
const now = Date.now();
+ const filesystemState = await resolveProjectFilesystemState(this.fsProvider, customPath);
groups.push({
id: encodedId,
@@ -374,6 +392,7 @@ export class ProjectScanner {
sessions: [],
totalSessions: 0,
createdAt: now,
+ filesystemState,
},
],
name: folderName,
@@ -550,6 +569,7 @@ export class ProjectScanner {
cwdHint: firstCwd ?? undefined,
sessionPaths,
});
+ const filesystemState = await resolveProjectFilesystemState(this.fsProvider, actualPath);
// Derive name from resolved path — more reliable than decodePath for
// paths containing dashes (e.g. "test-project" encodes lossily).
@@ -564,6 +584,7 @@ export class ProjectScanner {
totalSessions: allSessionIds.length,
createdAt: Math.floor(createdAt),
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
+ filesystemState,
},
];
}
@@ -623,6 +644,10 @@ export class ProjectScanner {
totalSessions: sessionIds.length,
createdAt: Math.floor(createdAt),
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
+ filesystemState: await resolveProjectFilesystemState(
+ this.fsProvider,
+ actualCwd ?? decodedFallback
+ ),
});
}
diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts
index 0458fd1d..bf92c60e 100644
--- a/src/main/services/infrastructure/CliInstallerService.ts
+++ b/src/main/services/infrastructure/CliInstallerService.ts
@@ -501,7 +501,7 @@ export class CliInstallerService {
},
{
providerId: 'opencode',
- displayName: 'OpenCode (75+ LLM providers)',
+ displayName: 'OpenCode (200+ models)',
},
] as const
).map((provider) => ({
diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts
index d79a7f4c..07443151 100644
--- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts
+++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts
@@ -326,7 +326,7 @@ function getProviderDisplayName(providerId: CliProviderId): string {
case 'gemini':
return 'Gemini';
case 'opencode':
- return 'OpenCode (75+ LLM providers)';
+ return 'OpenCode (200+ models)';
}
}
diff --git a/src/main/types/domain.ts b/src/main/types/domain.ts
index 1c2595ab..b0221f56 100644
--- a/src/main/types/domain.ts
+++ b/src/main/types/domain.ts
@@ -44,6 +44,8 @@ export type MessageCategory = 'user' | 'system' | 'hardNoise' | 'ai' | 'compact'
/**
* Project information derived from ~/.claude/projects/ directory.
*/
+export type ProjectFilesystemState = 'available' | 'deleted';
+
export interface Project {
/** Encoded directory name (e.g., "-Users-username-projectname") */
id: string;
@@ -62,6 +64,8 @@ export interface Project {
createdAt: number;
/** Unix timestamp of most recent session activity */
mostRecentSession?: number;
+ /** Filesystem state for the decoded working directory. */
+ filesystemState?: ProjectFilesystemState;
}
/**
@@ -202,6 +206,8 @@ export interface Worktree {
createdAt: number;
/** Unix timestamp of most recent session activity */
mostRecentSession?: number;
+ /** Filesystem state for this worktree path. */
+ filesystemState?: ProjectFilesystemState;
}
/**
diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx
index 2145b21f..d1f60117 100644
--- a/src/renderer/components/dashboard/CliStatusBanner.tsx
+++ b/src/renderer/components/dashboard/CliStatusBanner.tsx
@@ -385,7 +385,7 @@ function getProviderLabel(providerId: CliProviderId): string {
case 'gemini':
return 'Gemini';
case 'opencode':
- return 'OpenCode (75+ LLM providers)';
+ return 'OpenCode (200+ models)';
}
}
diff --git a/src/renderer/components/layout/TeamTabSectionNav.tsx b/src/renderer/components/layout/TeamTabSectionNav.tsx
index 2ba585ff..4cd48f64 100644
--- a/src/renderer/components/layout/TeamTabSectionNav.tsx
+++ b/src/renderer/components/layout/TeamTabSectionNav.tsx
@@ -33,7 +33,7 @@ export const TeamTabSectionNav = ({
if (messagesPanelMode === 'sidebar') {
return section.id !== 'messages' && section.id !== 'claude-logs';
}
- if (messagesPanelMode === 'bottom-sheet') {
+ if (messagesPanelMode === 'bottom-sheet' || messagesPanelMode === 'floating-composer') {
return section.id !== 'messages';
}
return true;
diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx
index 85a49f2d..1416f917 100644
--- a/src/renderer/components/settings/sections/CliStatusSection.tsx
+++ b/src/renderer/components/settings/sections/CliStatusSection.tsx
@@ -123,7 +123,7 @@ function getProviderLabel(providerId: CliProviderId): string {
case 'gemini':
return 'Gemini';
case 'opencode':
- return 'OpenCode (75+ LLM providers)';
+ return 'OpenCode (200+ models)';
}
}
diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx
index b8c900a4..36d545b5 100644
--- a/src/renderer/components/team/TeamDetailView.tsx
+++ b/src/renderer/components/team/TeamDetailView.tsx
@@ -3623,9 +3623,18 @@ export const TeamDetailView = memo(function TeamDetailView({
ref={setMessagesPanelMountPoint}
className="pointer-events-none absolute inset-0 z-30"
/>
- {messagesPanelMode === 'bottom-sheet' && (
+ {messagesPanelMode === 'bottom-sheet' && !graphOpen && (
)}
+ {messagesPanelMode === 'floating-composer' &&
+ isThisTabActive &&
+ isPaneFocused &&
+ !graphOpen && (
+
+ )}
@@ -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 = (
)}
-
-
- setMessagesCollapsed((v) => !v)}
- aria-label={messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
- >
- {messagesCollapsed ? : }
-
-
-
- {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
-
-
-
-
- setMessagesSearchBarVisible((v) => !v)}
- aria-label={
- messagesSearchBarVisible ? 'Hide message search' : 'Show message search'
- }
- >
- {messagesSearchBarVisible ? : }
-
-
-
- {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
;
@@ -1196,114 +1282,74 @@ export const MessagesPanel = memo(function MessagesPanel({
className="ml-auto flex items-center gap-1"
onPointerDown={(e) => e.stopPropagation()}
>
- {messagesUnreadCount > 0 && (
+
-
-
-
+
+
+
+
+
- Mark all as read
+ Message actions
- )}
-
-
- setMessagesCollapsed((value) => !value)}
- aria-label={
- messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'
- }
- >
+
+ {messagesUnreadCount > 0 && (
+
+
+ Mark all as read
+
+ )}
+ setMessagesCollapsed((value) => !value)}>
{messagesCollapsed ? (
-
+
) : (
-
+
)}
-
-
-
- {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
-
-
-
-
- setMessagesSearchBarVisible((value) => !value)}
- aria-label={
- messagesSearchBarVisible ? 'Hide message search' : 'Show message search'
- }
- >
- {messagesSearchBarVisible ? : }
-
-
-
- {messagesSearchBarVisible ? 'Hide search' : 'Search messages'}
-
-
-
-
-
+ {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
+
+
+ setMessagesSearchBarVisible((value) => !value)}
>
+ {messagesSearchBarVisible ? (
+
+ ) : (
+
+ )}
+ {messagesSearchBarVisible ? 'Hide search' : 'Search messages'}
+
+
{isBottomSheetCollapsed ? (
-
+
) : (
-
+
)}
-
-
-
- {isBottomSheetCollapsed ? 'Expand sheet' : 'Collapse sheet'}
-
-
-
-
-
-
-
-
- Move to inline
-
-
-
-
-
-
-
- Move to sidebar
-
+ {isBottomSheetCollapsed ? 'Expand sheet' : 'Collapse sheet'}
+
+
+
+ Move to inline
+
+
+
+ Move to sidebar
+
+
+
+ Float composer
+
+
+
@@ -1386,6 +1432,23 @@ export const MessagesPanel = memo(function MessagesPanel({
Move to bottom sheet
+
+
+ {
+ e.stopPropagation();
+ moveToFloatingComposer();
+ }}
+ aria-label="Float messages composer"
+ >
+
+
+
+ Float composer
+
;
}
@@ -133,12 +134,20 @@ export const Combobox = ({
{
+ if (option.disabled) {
+ return;
+ }
onValueChange(option.value);
setOpen(false);
setSearch('');
}}
- className="relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
+ className={cn(
+ 'relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]',
+ option.disabled && 'cursor-not-allowed opacity-60'
+ )}
>
{renderOption ? (
renderOption(option, isSelected, search)
diff --git a/src/renderer/components/ui/dropdown-menu.tsx b/src/renderer/components/ui/dropdown-menu.tsx
new file mode 100644
index 00000000..d1b46d96
--- /dev/null
+++ b/src/renderer/components/ui/dropdown-menu.tsx
@@ -0,0 +1,65 @@
+/* eslint-disable react/jsx-props-no-spreading -- Standard shadcn pattern: forward remaining props to underlying elements */
+import * as React from 'react';
+
+import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
+import { cn } from '@renderer/lib/utils';
+
+const DropdownMenu = DropdownMenuPrimitive.Root;
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
+
+const DropdownMenuContent = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+));
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
+
+const DropdownMenuItem = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
+
+export {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+};
+/* eslint-enable react/jsx-props-no-spreading -- Re-enable after shadcn component */
diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts
index d8213c10..40054525 100644
--- a/src/renderer/store/slices/cliInstallerSlice.ts
+++ b/src/renderer/store/slices/cliInstallerSlice.ts
@@ -38,7 +38,7 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
{ providerId: 'anthropic', displayName: 'Anthropic' },
{ providerId: 'codex', displayName: 'Codex' },
{ providerId: 'gemini', displayName: 'Gemini' },
- { providerId: 'opencode', displayName: 'OpenCode (75+ LLM providers)' },
+ { providerId: 'opencode', displayName: 'OpenCode (200+ models)' },
] as const
).map((provider) => ({
...provider,
@@ -500,7 +500,7 @@ function getProviderDisplayName(providerId: CliProviderId): string {
case 'gemini':
return 'Gemini';
case 'opencode':
- return 'OpenCode (75+ LLM providers)';
+ return 'OpenCode (200+ models)';
}
}
diff --git a/src/renderer/types/teamMessagesPanelMode.ts b/src/renderer/types/teamMessagesPanelMode.ts
index 87fbaaf1..85f90ec3 100644
--- a/src/renderer/types/teamMessagesPanelMode.ts
+++ b/src/renderer/types/teamMessagesPanelMode.ts
@@ -1 +1 @@
-export type TeamMessagesPanelMode = 'sidebar' | 'inline' | 'bottom-sheet';
+export type TeamMessagesPanelMode = 'sidebar' | 'inline' | 'bottom-sheet' | 'floating-composer';
diff --git a/test/features/recent-projects/core/domain/mergeRecentProjectCandidates.test.ts b/test/features/recent-projects/core/domain/mergeRecentProjectCandidates.test.ts
index fd6e655e..556949c6 100644
--- a/test/features/recent-projects/core/domain/mergeRecentProjectCandidates.test.ts
+++ b/test/features/recent-projects/core/domain/mergeRecentProjectCandidates.test.ts
@@ -96,4 +96,40 @@ describe('mergeRecentProjectCandidates', () => {
expect(result[0].identity).toBe('repo:beta');
expect(result[0].branchName).toBeUndefined();
});
+
+ it('prefers an available candidate over a newer deleted path', () => {
+ const result = mergeRecentProjectCandidates([
+ makeCandidate({
+ lastActivityAt: 1_000,
+ primaryPath: '/workspace/alpha',
+ associatedPaths: ['/workspace/alpha'],
+ filesystemState: 'available',
+ openTarget: {
+ type: 'synthetic-path',
+ path: '/workspace/alpha',
+ },
+ }),
+ makeCandidate({
+ lastActivityAt: 5_000,
+ primaryPath: '/workspace/alpha-deleted',
+ associatedPaths: ['/workspace/alpha-deleted'],
+ filesystemState: 'deleted',
+ openTarget: {
+ type: 'synthetic-path',
+ path: '/workspace/alpha-deleted',
+ },
+ }),
+ ]);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({
+ primaryPath: '/workspace/alpha',
+ lastActivityAt: 5_000,
+ filesystemState: 'available',
+ openTarget: {
+ type: 'synthetic-path',
+ path: '/workspace/alpha',
+ },
+ });
+ });
});
diff --git a/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts b/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts
index afc4418b..0e429ea5 100644
--- a/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts
+++ b/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts
@@ -114,6 +114,42 @@ describe('CodexSessionFileRecentProjectsSourceAdapter', () => {
expect(identityResolver.resolve).toHaveBeenCalledWith('/Users/test/projects/alpha');
});
+ it('marks a Codex session project as deleted when its cwd is gone', async () => {
+ const codexHome = path.join(tempDir, '.codex');
+ const logger = createLogger();
+ const identityResolver = {
+ resolve: vi.fn().mockResolvedValue(null),
+ } as unknown as RecentProjectIdentityResolver;
+ const fsProvider = {
+ exists: vi.fn().mockResolvedValue(false),
+ };
+ await writeRollout(
+ path.join(codexHome, 'sessions', '2026', '04', '14', 'rollout-deleted.jsonl'),
+ {
+ cwd: '/Users/test/projects/deleted',
+ },
+ new Date('2026-04-14T12:00:00.000Z')
+ );
+
+ const adapter = new CodexSessionFileRecentProjectsSourceAdapter({
+ getActiveContext: () => ({ type: 'local', id: 'local-1', fsProvider }) as never,
+ getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never,
+ identityResolver,
+ logger,
+ codexHome,
+ });
+
+ const result = await adapter.list();
+
+ expect(result.candidates[0]).toEqual(
+ expect.objectContaining({
+ primaryPath: '/Users/test/projects/deleted',
+ filesystemState: 'deleted',
+ })
+ );
+ expect(fsProvider.exists).toHaveBeenCalledWith('/Users/test/projects/deleted');
+ });
+
it('loads Codex projects from large session metadata lines without parsing the full line', async () => {
const codexHome = path.join(tempDir, '.codex');
const logger = createLogger();
diff --git a/test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts b/test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts
index 90e65166..35ee6e31 100644
--- a/test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts
+++ b/test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts
@@ -21,6 +21,7 @@ describe('adaptRecentProjectsSection', () => {
worktreeId: 'wt-alpha',
},
primaryBranch: 'main',
+ filesystemState: 'deleted',
};
const activeTeam: TeamSummary = {
@@ -52,11 +53,11 @@ describe('adaptRecentProjectsSection', () => {
taskCounts: { pending: 5, inProgress: 7, completed: 9 },
additionalPathCount: 1,
primaryBranch: 'main',
+ filesystemState: 'deleted',
activeTeams: [activeTeam],
pathSummary: {
badgeLabel: '2 paths',
- description:
- 'This card merges recent activity from related worktrees and project paths.',
+ description: 'This card merges recent activity from related worktrees and project paths.',
paths: [
{
label: 'Primary path',
diff --git a/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts b/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts
index a26ddc14..1d5047d3 100644
--- a/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts
+++ b/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts
@@ -153,4 +153,26 @@ describe('ProjectScanner cwd split logic', () => {
expect(worktree?.isMainWorktree).toBe(false);
expect(worktree?.source).toBe('claude-desktop');
});
+
+ it('marks decoded project paths as deleted when the working directory no longer exists', async () => {
+ const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-'));
+ tempDirs.push(projectsDir);
+
+ const encodedName = '-Users-test-deleted-project';
+ const projectDir = path.join(projectsDir, encodedName);
+ fs.mkdirSync(projectDir);
+
+ fs.writeFileSync(
+ path.join(projectDir, 'session-deleted.jsonl'),
+ createSessionLine({ cwd: '/Users/test/deleted-project' }) + '\n'
+ );
+
+ const scanner = new ProjectScanner(projectsDir);
+ const projects = await scanner.scan();
+
+ expect(projects[0]).toMatchObject({
+ path: '/Users/test/deleted-project',
+ filesystemState: 'deleted',
+ });
+ });
});
diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts
index 39e08c66..140454b7 100644
--- a/test/main/services/infrastructure/CliInstallerService.test.ts
+++ b/test/main/services/infrastructure/CliInstallerService.test.ts
@@ -151,7 +151,7 @@ describe('CliInstallerService', () => {
'opencode',
]);
expect(openCodeStatus).toMatchObject({
- displayName: 'OpenCode (75+ LLM providers)',
+ displayName: 'OpenCode (200+ models)',
supported: false,
statusMessage: 'Runtime not found.',
canLoginFromUi: false,
diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts
index dd039aec..df94eae6 100644
--- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts
+++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts
@@ -221,7 +221,7 @@ describe('ClaudeMultimodelBridgeService', () => {
});
expect(providers[3]).toMatchObject({
providerId: 'opencode',
- displayName: 'OpenCode (75+ LLM providers)',
+ displayName: 'OpenCode (200+ models)',
supported: false,
authenticated: false,
models: [],
diff --git a/test/renderer/components/cli/CliStatusVisibility.test.ts b/test/renderer/components/cli/CliStatusVisibility.test.ts
index ff2affe4..061853eb 100644
--- a/test/renderer/components/cli/CliStatusVisibility.test.ts
+++ b/test/renderer/components/cli/CliStatusVisibility.test.ts
@@ -450,7 +450,7 @@ describe('CLI status visibility during completed install state', () => {
providers: [
{
providerId: 'opencode',
- displayName: 'OpenCode (75+ LLM providers)',
+ displayName: 'OpenCode (200+ models)',
supported: false,
authenticated: false,
authMethod: null,
@@ -476,7 +476,7 @@ describe('CLI status visibility during completed install state', () => {
await Promise.resolve();
});
- expect(host.textContent).toContain('OpenCode (75+ LLM providers)');
+ expect(host.textContent).toContain('OpenCode (200+ models)');
expect(host.textContent).toContain('Install');
const installButton = Array.from(host.querySelectorAll('button')).find(
@@ -523,7 +523,7 @@ describe('CLI status visibility during completed install state', () => {
providers: [
{
providerId: 'opencode',
- displayName: 'OpenCode (75+ LLM providers)',
+ displayName: 'OpenCode (200+ models)',
supported: false,
authenticated: false,
authMethod: null,
@@ -575,7 +575,7 @@ describe('CLI status visibility during completed install state', () => {
providers: [
{
providerId: 'opencode',
- displayName: 'OpenCode (75+ LLM providers)',
+ displayName: 'OpenCode (200+ models)',
supported: true,
authenticated: true,
authMethod: 'opencode_managed',
diff --git a/test/renderer/components/extensions/skills/SkillsPanel.test.ts b/test/renderer/components/extensions/skills/SkillsPanel.test.ts
index a19ce837..b71bae74 100644
--- a/test/renderer/components/extensions/skills/SkillsPanel.test.ts
+++ b/test/renderer/components/extensions/skills/SkillsPanel.test.ts
@@ -559,7 +559,7 @@ describe('SkillsPanel', () => {
});
expect(host.textContent).toContain(
- 'Shared skills in `.claude`, `.cursor`, and `.agents` are available to Anthropic, Codex, and OpenCode (75+ LLM providers).'
+ 'Shared skills in `.claude`, `.cursor`, and `.agents` are available to Anthropic, Codex, and OpenCode (200+ models).'
);
expect(host.textContent).toContain('Codex only');
diff --git a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts
index 4f794a3a..d8d33952 100644
--- a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts
+++ b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts
@@ -1405,6 +1405,12 @@ describe('LaunchTeamDialog', () => {
expect(host.textContent).toContain('model:claude-opus-4-6');
expect(host.textContent).toContain('effort:max');
expect(host.textContent).toContain('fast:on');
+ expect(host.textContent).toContain('monthly Agent SDK credit');
+ expect(
+ host.querySelector(
+ 'a[href="https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan"]'
+ )
+ ).toBeTruthy();
const submitButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent === 'Save Changes'
diff --git a/test/renderer/components/team/dialogs/projectPathOptions.test.ts b/test/renderer/components/team/dialogs/projectPathOptions.test.ts
index 33685f88..2af0d127 100644
--- a/test/renderer/components/team/dialogs/projectPathOptions.test.ts
+++ b/test/renderer/components/team/dialogs/projectPathOptions.test.ts
@@ -88,4 +88,27 @@ describe('buildProjectPathOptions', () => {
},
]);
});
+
+ it('marks deleted project paths as disabled options', () => {
+ const options = buildProjectPathOptions([
+ createProject({
+ id: 'project-deleted',
+ name: 'my-tes',
+ path: '/Users/belief/dev/projects/my-tes',
+ filesystemState: 'deleted',
+ }),
+ ]);
+
+ expect(options).toEqual([
+ {
+ value: '/Users/belief/dev/projects/my-tes',
+ label: 'my-tes',
+ description: '/Users/belief/dev/projects/my-tes',
+ disabled: true,
+ meta: {
+ filesystemState: 'deleted',
+ },
+ },
+ ]);
+ });
});
From 4adc233fa4bf71fcc7d908b98a3a8bb4cac96e9e Mon Sep 17 00:00:00 2001
From: infiniti <52129260+developerInfiniti@users.noreply.github.com>
Date: Sat, 16 May 2026 17:34:50 +0300
Subject: [PATCH 7/9] fix: harden Windows frontend path handling
Harden Windows path handling and packaged app smoke checks.
---
scripts/electron-builder/dist-invocations.cjs | 51 +++++++++
scripts/electron-builder/dist.mjs | 49 +--------
scripts/electron-builder/smokePackagedApp.cjs | 56 +++++++++-
.../utils/recentProjectOpenHistory.ts | 3 +-
src/main/services/editor/FileSearchService.ts | 4 +-
.../DirectoryTree/buildDirectoryTree.ts | 11 +-
.../components/chat/viewers/FileLink.tsx | 15 ++-
.../chat/viewers/MarkdownViewer.tsx | 6 +-
.../settings/sections/GeneralSection.tsx | 4 +-
.../team/editor/EditorContextMenu.tsx | 9 +-
.../team/editor/SearchInFilesPanel.tsx | 8 +-
src/renderer/hooks/useFileSuggestions.ts | 25 +++--
src/renderer/store/utils/pathResolution.ts | 29 ++---
src/renderer/utils/claudeMdTracker.ts | 69 ++++++------
src/renderer/utils/contextTracker.ts | 99 +++++++++++------
src/renderer/utils/pathDisplay.ts | 24 ++--
src/shared/utils/platformPath.ts | 21 ++++
.../utils/recentProjectOpenHistory.test.ts | 19 ++++
.../build/electronBuilderDistScript.test.ts | 10 +-
.../services/editor/FileSearchService.test.ts | 25 +++++
.../buildDirectoryTree.test.ts | 47 ++++++++
.../chat/viewers/MarkdownViewer.test.tsx | 25 ++++-
test/renderer/components/fileLink.test.ts | 15 +++
.../renderer/hooks/useFileSuggestions.test.ts | 41 ++++++-
test/renderer/store/pathResolution.test.ts | 24 ++++
test/renderer/utils/claudeMdTracker.test.ts | 103 ++++++++++++++++++
test/renderer/utils/contextTracker.test.ts | 66 +++++++++++
test/renderer/utils/pathDisplay.test.ts | 27 ++++-
test/shared/utils/platformPath.test.ts | 38 +++++++
29 files changed, 714 insertions(+), 209 deletions(-)
create mode 100644 scripts/electron-builder/dist-invocations.cjs
create mode 100644 test/renderer/components/chat/SessionContextPanel/buildDirectoryTree.test.ts
create mode 100644 test/renderer/utils/contextTracker.test.ts
create mode 100644 test/shared/utils/platformPath.test.ts
diff --git a/scripts/electron-builder/dist-invocations.cjs b/scripts/electron-builder/dist-invocations.cjs
new file mode 100644
index 00000000..a8f7db1c
--- /dev/null
+++ b/scripts/electron-builder/dist-invocations.cjs
@@ -0,0 +1,51 @@
+const PLATFORM_FLAGS = new Map([
+ ['--mac', 'mac'],
+ ['-m', 'mac'],
+ ['--win', 'win'],
+ ['-w', 'win'],
+ ['--linux', 'linux'],
+ ['-l', 'linux'],
+]);
+
+const PLATFORM_ARGS = {
+ mac: '--mac',
+ win: '--win',
+ linux: '--linux',
+};
+
+const LINUX_PACKAGE_NAME_OVERRIDES = [
+ '--config.productName=Agent-Teams-UI',
+ '--config.linux.desktop.entry.Name=Agent Teams UI',
+];
+
+function buildElectronBuilderInvocations(argv) {
+ const targets = [];
+ const sharedArgs = [];
+
+ for (const arg of argv) {
+ const target = PLATFORM_FLAGS.get(arg);
+ if (target) {
+ if (!targets.includes(target)) {
+ targets.push(target);
+ }
+ continue;
+ }
+ sharedArgs.push(arg);
+ }
+
+ if (targets.length === 0) {
+ return [{ args: sharedArgs }];
+ }
+
+ return targets.map((target) => ({
+ args: [
+ PLATFORM_ARGS[target],
+ ...sharedArgs,
+ ...(target === 'linux' ? LINUX_PACKAGE_NAME_OVERRIDES : []),
+ ],
+ }));
+}
+
+module.exports = {
+ buildElectronBuilderInvocations,
+};
diff --git a/scripts/electron-builder/dist.mjs b/scripts/electron-builder/dist.mjs
index e3c72615..cc98ed61 100644
--- a/scripts/electron-builder/dist.mjs
+++ b/scripts/electron-builder/dist.mjs
@@ -4,54 +4,9 @@ import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
const require = createRequire(import.meta.url);
+const { buildElectronBuilderInvocations } = require('./dist-invocations.cjs');
-const PLATFORM_FLAGS = new Map([
- ['--mac', 'mac'],
- ['-m', 'mac'],
- ['--win', 'win'],
- ['-w', 'win'],
- ['--linux', 'linux'],
- ['-l', 'linux'],
-]);
-
-const PLATFORM_ARGS = {
- mac: '--mac',
- win: '--win',
- linux: '--linux',
-};
-
-const LINUX_PACKAGE_NAME_OVERRIDES = [
- '--config.productName=Agent-Teams-UI',
- '--config.linux.desktop.entry.Name=Agent Teams UI',
-];
-
-export function buildElectronBuilderInvocations(argv) {
- const targets = [];
- const sharedArgs = [];
-
- for (const arg of argv) {
- const target = PLATFORM_FLAGS.get(arg);
- if (target) {
- if (!targets.includes(target)) {
- targets.push(target);
- }
- continue;
- }
- sharedArgs.push(arg);
- }
-
- if (targets.length === 0) {
- return [{ args: sharedArgs }];
- }
-
- return targets.map((target) => ({
- args: [
- PLATFORM_ARGS[target],
- ...sharedArgs,
- ...(target === 'linux' ? LINUX_PACKAGE_NAME_OVERRIDES : []),
- ],
- }));
-}
+export { buildElectronBuilderInvocations };
async function runElectronBuilder(args) {
const cliPath = require.resolve('electron-builder/cli.js');
diff --git a/scripts/electron-builder/smokePackagedApp.cjs b/scripts/electron-builder/smokePackagedApp.cjs
index 68594830..9892a072 100644
--- a/scripts/electron-builder/smokePackagedApp.cjs
+++ b/scripts/electron-builder/smokePackagedApp.cjs
@@ -5,6 +5,7 @@ const { spawn } = require('node:child_process');
const STARTUP_TIMEOUT_MS = Number(process.env.PACKAGED_SMOKE_TIMEOUT_MS ?? 30_000);
const POST_STARTUP_STABLE_MS = Number(process.env.PACKAGED_SMOKE_STABLE_MS ?? 8_000);
+const SHUTDOWN_TIMEOUT_MS = Number(process.env.PACKAGED_SMOKE_SHUTDOWN_TIMEOUT_MS ?? 5_000);
const REQUIRED_LOG_MARKERS = ['renderer did-finish-load'];
const FAILURE_PATTERNS = [
/Cannot find module/i,
@@ -39,7 +40,10 @@ function findExecutable(bundlePath, platform) {
if (platform === 'win32') {
const executable = fs
.readdirSync(bundlePath)
- .find((entry) => entry.toLowerCase().endsWith('.exe') && !entry.toLowerCase().includes('uninstall'));
+ .find(
+ (entry) =>
+ entry.toLowerCase().endsWith('.exe') && !entry.toLowerCase().includes('uninstall')
+ );
if (!executable) fail(`No .exe found in ${bundlePath}`);
return path.join(bundlePath, executable);
}
@@ -66,6 +70,45 @@ function findExecutable(bundlePath, platform) {
fail(`Unsupported platform: ${platform}`);
}
+function waitForProcessClose(child, exitPromise, timeoutMs) {
+ if (child.exitCode !== null || child.signalCode !== null) {
+ return Promise.resolve(true);
+ }
+
+ let timeoutId;
+ const timeoutPromise = new Promise((resolve) => {
+ timeoutId = setTimeout(() => resolve(false), timeoutMs);
+ });
+ return Promise.race([exitPromise.then(() => true), timeoutPromise]).finally(() => {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ });
+}
+
+async function terminateChild(child, exitPromise, platform) {
+ if (child.exitCode !== null || child.signalCode !== null) {
+ return;
+ }
+
+ if (platform === 'win32' && child.pid) {
+ await new Promise((resolve) => {
+ const killer = spawn('taskkill.exe', ['/pid', String(child.pid), '/T', '/F'], {
+ stdio: 'ignore',
+ });
+ killer.once('error', resolve);
+ killer.once('close', resolve);
+ });
+ } else {
+ child.kill();
+ }
+
+ const closed = await waitForProcessClose(child, exitPromise, SHUTDOWN_TIMEOUT_MS);
+ if (!closed && child.exitCode === null && child.signalCode === null) {
+ throw new Error(`Timed out after ${SHUTDOWN_TIMEOUT_MS}ms waiting for packaged app to exit`);
+ }
+}
+
async function main() {
const [bundlePathArg, platform] = process.argv.slice(2);
if (!bundlePathArg || !platform) {
@@ -100,7 +143,7 @@ async function main() {
let startupSeenAt = null;
while (Date.now() < deadline) {
if (FAILURE_PATTERNS.some((pattern) => pattern.test(log))) {
- child.kill();
+ await terminateChild(child, exitPromise, platform);
fail('Detected startup failure pattern', log);
}
@@ -109,7 +152,7 @@ async function main() {
}
if (startupSeenAt !== null && Date.now() - startupSeenAt >= POST_STARTUP_STABLE_MS) {
- child.kill();
+ await terminateChild(child, exitPromise, platform);
console.log(`[smokePackagedApp] OK ${platform}: ${bundlePath}`);
return;
}
@@ -119,11 +162,14 @@ async function main() {
new Promise((resolve) => setTimeout(() => resolve(null), 250)),
]);
if (exit) {
- fail(`Packaged app exited before startup completed: code=${exit.code} signal=${exit.signal}`, log);
+ fail(
+ `Packaged app exited before startup completed: code=${exit.code} signal=${exit.signal}`,
+ log
+ );
}
}
- child.kill();
+ await terminateChild(child, exitPromise, platform);
fail(`Timed out after ${STARTUP_TIMEOUT_MS}ms waiting for packaged startup`, log);
}
diff --git a/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts b/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts
index 28d8b880..0b3be39c 100644
--- a/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts
+++ b/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts
@@ -1,4 +1,5 @@
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
+import { normalizePathForComparison } from '@shared/utils/platformPath';
import type { DashboardRecentProject } from '@features/recent-projects/contracts';
@@ -34,7 +35,7 @@ function normalizeHistoryPath(projectPath: string): string | null {
normalizedPath = normalizedPath.slice(0, -1);
}
}
- return normalizedPath;
+ return normalizedPath ? normalizePathForComparison(normalizedPath) : null;
}
function foldHistoryPath(projectPath: string): string {
diff --git a/src/main/services/editor/FileSearchService.ts b/src/main/services/editor/FileSearchService.ts
index 52ce0927..691f3603 100644
--- a/src/main/services/editor/FileSearchService.ts
+++ b/src/main/services/editor/FileSearchService.ts
@@ -292,9 +292,7 @@ export class FileSearchService {
subdirs.push(fullPath);
} else if (entry.isFile()) {
if (IGNORED_FILES.has(entry.name)) continue;
- const relativePath = fullPath.startsWith(projectRoot)
- ? fullPath.slice(projectRoot.length + 1)
- : entry.name;
+ const relativePath = path.relative(projectRoot, fullPath).split(path.sep).join('/');
files.push({ path: fullPath, name: entry.name, relativePath });
}
}
diff --git a/src/renderer/components/chat/SessionContextPanel/DirectoryTree/buildDirectoryTree.ts b/src/renderer/components/chat/SessionContextPanel/DirectoryTree/buildDirectoryTree.ts
index 48e38d43..ec68af98 100644
--- a/src/renderer/components/chat/SessionContextPanel/DirectoryTree/buildDirectoryTree.ts
+++ b/src/renderer/components/chat/SessionContextPanel/DirectoryTree/buildDirectoryTree.ts
@@ -2,6 +2,8 @@
* Build a directory tree structure from CLAUDE.md injections.
*/
+import { getRelativePathWithinPrefix } from '@shared/utils/platformPath';
+
import type { TreeNode } from './types';
import type { ClaudeMdContextInjection } from '@renderer/types/contextInjection';
@@ -15,12 +17,9 @@ export function buildDirectoryTree(
const root: TreeNode = { name: '', path: '', isFile: false, children: new Map() };
for (const injection of injections) {
- let relativePath = injection.path;
- if (projectRoot && relativePath.startsWith(projectRoot)) {
- relativePath = relativePath.slice(projectRoot.length);
- if (relativePath.startsWith('/') || relativePath.startsWith('\\'))
- relativePath = relativePath.slice(1);
- }
+ const relativePath = projectRoot
+ ? (getRelativePathWithinPrefix(projectRoot, injection.path) ?? injection.path)
+ : injection.path;
const parts = relativePath.split(/[\\/]/);
let current = root;
diff --git a/src/renderer/components/chat/viewers/FileLink.tsx b/src/renderer/components/chat/viewers/FileLink.tsx
index 7ed49cf7..4b1017b8 100644
--- a/src/renderer/components/chat/viewers/FileLink.tsx
+++ b/src/renderer/components/chat/viewers/FileLink.tsx
@@ -57,21 +57,20 @@ export function resolveFileLinkPath(filePath: string, projectPath: string): stri
function normalizePathSegments(filePath: string): string {
const hasBackslash = filePath.includes('\\') && !filePath.includes('/');
const separator = hasBackslash ? '\\' : '/';
- const normalized = filePath.replace(/[/\\]+/g, separator);
let prefix = '';
- let body = normalized;
+ let body = filePath;
- const driveMatch = /^([A-Za-z]:)[\\/]/.exec(normalized);
+ const driveMatch = /^([A-Za-z]:)[\\/]/.exec(filePath);
if (driveMatch) {
prefix = `${driveMatch[1]}${separator}`;
- body = normalized.slice(prefix.length);
- } else if (normalized.startsWith(`${separator}${separator}`)) {
+ body = filePath.slice(driveMatch[0].length);
+ } else if (filePath.startsWith('\\\\') || filePath.startsWith('//')) {
prefix = `${separator}${separator}`;
- body = normalized.slice(2);
- } else if (normalized.startsWith(separator)) {
+ body = filePath.slice(2);
+ } else if (filePath.startsWith('/') || filePath.startsWith('\\')) {
prefix = separator;
- body = normalized.slice(1);
+ body = filePath.slice(1);
}
const segments: string[] = [];
diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx
index 90faaf38..cca878d4 100644
--- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx
+++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx
@@ -27,6 +27,7 @@ import {
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
+import { resolveFilePath } from '@renderer/store/utils/pathResolution';
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
import { nameColorSet } from '@renderer/utils/projectColor';
import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
@@ -318,9 +319,8 @@ function isAllowedElement(element: { tagName: string }): boolean {
}
/** Resolve a relative path to an absolute path given a base directory */
-function resolveRelativePath(relativeSrc: string, baseDir: string): string {
- const cleaned = relativeSrc.startsWith('./') ? relativeSrc.slice(2) : relativeSrc;
- return `${baseDir}/${cleaned}`;
+export function resolveRelativePath(relativeSrc: string, baseDir: string): string {
+ return resolveFilePath(baseDir, relativeSrc);
}
// =============================================================================
diff --git a/src/renderer/components/settings/sections/GeneralSection.tsx b/src/renderer/components/settings/sections/GeneralSection.tsx
index 0e653f52..dd0ef9a1 100644
--- a/src/renderer/components/settings/sections/GeneralSection.tsx
+++ b/src/renderer/components/settings/sections/GeneralSection.tsx
@@ -254,7 +254,9 @@ export const GeneralSection = ({
const resolvedClaudeRootPath = claudeRootInfo?.resolvedPath ?? '~/.claude';
const defaultClaudeRootPath = claudeRootInfo?.defaultPath ?? '~/.claude';
const isWindowsStyleDefaultPath =
- /^[a-zA-Z]:\\/.test(defaultClaudeRootPath) || defaultClaudeRootPath.startsWith('\\\\');
+ /^[a-zA-Z]:[/\\]/.test(defaultClaudeRootPath) ||
+ defaultClaudeRootPath.startsWith('\\\\') ||
+ defaultClaudeRootPath.startsWith('//');
const isElectron = useMemo(() => isElectronMode(), []);
diff --git a/src/renderer/components/team/editor/EditorContextMenu.tsx b/src/renderer/components/team/editor/EditorContextMenu.tsx
index 5a795ff9..befabb3f 100644
--- a/src/renderer/components/team/editor/EditorContextMenu.tsx
+++ b/src/renderer/components/team/editor/EditorContextMenu.tsx
@@ -9,7 +9,7 @@
import React, { useCallback, useRef, useState } from 'react';
import * as ContextMenu from '@radix-ui/react-context-menu';
-import { lastSeparatorIndex } from '@shared/utils/platformPath';
+import { getRelativePathWithinPrefix, lastSeparatorIndex } from '@shared/utils/platformPath';
import {
ClipboardCopy,
FilePlus,
@@ -87,6 +87,8 @@ export const EditorContextMenu = ({
? target.path
: target.path.substring(0, lastSeparatorIndex(target.path))
: null;
+ const targetRelativePath =
+ projectPath && target ? getRelativePathWithinPrefix(projectPath, target.path) : null;
return (
@@ -154,12 +156,11 @@ export const EditorContextMenu = ({
Copy Path
- {projectPath && target.path.startsWith(projectPath) && (
+ {targetRelativePath !== null && (
{
- const relative = target.path.slice(projectPath.length + 1);
- void navigator.clipboard.writeText(relative);
+ void navigator.clipboard.writeText(targetRelativePath);
}}
>
diff --git a/src/renderer/components/team/editor/SearchInFilesPanel.tsx b/src/renderer/components/team/editor/SearchInFilesPanel.tsx
index 4253a22d..81d57b58 100644
--- a/src/renderer/components/team/editor/SearchInFilesPanel.tsx
+++ b/src/renderer/components/team/editor/SearchInFilesPanel.tsx
@@ -10,7 +10,11 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
-import { getBasename, lastSeparatorIndex } from '@shared/utils/platformPath';
+import {
+ getBasename,
+ getRelativePathWithinPrefix,
+ lastSeparatorIndex,
+} from '@shared/utils/platformPath';
import { Loader2, Search, X } from 'lucide-react';
import { FileIcon } from './FileIcon';
@@ -146,7 +150,7 @@ export const SearchInFilesPanel = ({
const getRelativePath = useCallback(
(filePath: string) => {
- return filePath.startsWith(projectPath) ? filePath.slice(projectPath.length + 1) : filePath;
+ return getRelativePathWithinPrefix(projectPath, filePath) ?? filePath;
},
[projectPath]
);
diff --git a/src/renderer/hooks/useFileSuggestions.ts b/src/renderer/hooks/useFileSuggestions.ts
index 15b8541e..f5a8449c 100644
--- a/src/renderer/hooks/useFileSuggestions.ts
+++ b/src/renderer/hooks/useFileSuggestions.ts
@@ -13,6 +13,7 @@ import {
onQuickOpenCacheInvalidated,
setQuickOpenCache,
} from '@renderer/utils/quickOpenCache';
+import { joinPath, splitPath } from '@shared/utils/platformPath';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { QuickOpenFile } from '@shared/types/editor';
@@ -53,12 +54,12 @@ export function formatFileMentionPath(relativePath: string): string {
* Extracts unique directories from a list of file paths.
* Returns directories sorted by depth (shallower first), then alphabetically.
*/
-function extractDirectories(files: QuickOpenFile[], projectPath: string): DerivedFolder[] {
+export function extractDirectories(files: QuickOpenFile[], projectPath: string): DerivedFolder[] {
const dirSet = new Set();
for (const f of files) {
// Walk up the directory chain from each file's relative path
- const parts = f.relativePath.split('/');
+ const parts = splitPath(f.relativePath);
// Remove the file name — keep only directory segments
for (let i = 1; i < parts.length; i++) {
dirSet.add(parts.slice(0, i).join('/'));
@@ -67,19 +68,19 @@ function extractDirectories(files: QuickOpenFile[], projectPath: string): Derive
const folders: DerivedFolder[] = [];
for (const relDir of dirSet) {
- const segments = relDir.split('/');
+ const segments = splitPath(relDir);
const name = segments[segments.length - 1];
folders.push({
name,
relativePath: relDir + '/',
- absolutePath: projectPath + '/' + relDir,
+ absolutePath: joinPath(projectPath, ...splitPath(relDir)),
});
}
// Sort: shallower first, then alphabetically
folders.sort((a, b) => {
- const depthA = a.relativePath.split('/').length;
- const depthB = b.relativePath.split('/').length;
+ const depthA = splitPath(a.relativePath).length;
+ const depthB = splitPath(b.relativePath).length;
if (depthA !== depthB) return depthA - depthB;
return a.relativePath.localeCompare(b.relativePath);
});
@@ -94,13 +95,14 @@ function extractDirectories(files: QuickOpenFile[], projectPath: string): Derive
export function filterFileSuggestions(files: QuickOpenFile[], query: string): MentionSuggestion[] {
if (!query || files.length === 0) return [];
- const lower = query.toLowerCase();
+ const lower = query.replace(/\\/g, '/').toLowerCase();
const results: MentionSuggestion[] = [];
for (const f of files) {
if (results.length >= MAX_FILE_SUGGESTIONS) break;
- if (f.name.toLowerCase().includes(lower) || f.relativePath.toLowerCase().includes(lower)) {
+ const relativePathForMatch = f.relativePath.replace(/\\/g, '/').toLowerCase();
+ if (f.name.toLowerCase().includes(lower) || relativePathForMatch.includes(lower)) {
results.push({
id: `file:${f.path}`,
name: f.name,
@@ -127,14 +129,15 @@ export function filterFolderSuggestions(
if (!query || folders.length === 0) return [];
// Strip trailing slash from query for matching (e.g. "ui/" -> "ui")
- const cleanQuery = query.endsWith('/') ? query.slice(0, -1) : query;
- const lower = cleanQuery.toLowerCase();
+ const cleanQuery = query.endsWith('/') || query.endsWith('\\') ? query.slice(0, -1) : query;
+ const lower = cleanQuery.replace(/\\/g, '/').toLowerCase();
const results: MentionSuggestion[] = [];
for (const f of folders) {
if (results.length >= MAX_FOLDER_SUGGESTIONS) break;
- if (f.name.toLowerCase().includes(lower) || f.relativePath.toLowerCase().includes(lower)) {
+ const relativePathForMatch = f.relativePath.replace(/\\/g, '/').toLowerCase();
+ if (f.name.toLowerCase().includes(lower) || relativePathForMatch.includes(lower)) {
results.push({
id: `folder:${f.absolutePath}`,
name: f.name + '/',
diff --git a/src/renderer/store/utils/pathResolution.ts b/src/renderer/store/utils/pathResolution.ts
index 7d520af8..43b58f0f 100644
--- a/src/renderer/store/utils/pathResolution.ts
+++ b/src/renderer/store/utils/pathResolution.ts
@@ -2,6 +2,8 @@
* Path resolution utilities for the store.
*/
+import { stripTrailingSeparators } from '@shared/utils/platformPath';
+
/**
* Resolves a relative path against a base path, handling various path formats.
* Handles:
@@ -17,7 +19,7 @@ export function resolveFilePath(base: string, relativePath: string): string {
return relativePath;
}
- const cleanBase = trimTrailingSeparator(base);
+ const cleanBase = stripTrailingSeparators(base);
// Handle @ prefix (file mention marker) - strip it if present
let cleanRelative = relativePath;
@@ -32,21 +34,22 @@ export function resolveFilePath(base: string, relativePath: string): string {
}
// Handle ./ prefix (current directory)
- if (cleanRelative.startsWith('./')) {
+ if (cleanRelative.startsWith('./') || cleanRelative.startsWith('.\\')) {
cleanRelative = cleanRelative.slice(2);
}
// Handle ../ prefixes (parent directory)
const separator = cleanBase.includes('\\') ? '\\' : '/';
- const hasUnixRoot = cleanBase.startsWith('/');
- const hasUncRoot = cleanBase.startsWith('\\\\');
+ const hasUncRoot = cleanBase.startsWith('\\\\') || cleanBase.startsWith('//');
+ const hasUnixRoot = !hasUncRoot && cleanBase.startsWith('/');
+ const minRootParts = hasUncRoot ? 2 : 1;
const normalizedRelative = normalizeSeparators(cleanRelative, separator);
const baseParts = splitPath(cleanBase);
let remainingRelative = normalizedRelative;
while (remainingRelative.startsWith(`..${separator}`)) {
remainingRelative = remainingRelative.slice(3);
- if (baseParts.length > 1) {
+ if (baseParts.length > minRootParts) {
baseParts.pop();
}
}
@@ -56,8 +59,8 @@ export function resolveFilePath(base: string, relativePath: string): string {
if (hasUnixRoot && !normalizedBase.startsWith('/')) {
normalizedBase = `/${normalizedBase}`;
}
- if (hasUncRoot && !normalizedBase.startsWith('\\\\')) {
- normalizedBase = `\\\\${normalizedBase}`;
+ if (hasUncRoot && !normalizedBase.startsWith(`${separator}${separator}`)) {
+ normalizedBase = `${separator}${separator}${normalizedBase}`;
}
return remainingRelative ? `${normalizedBase}${separator}${remainingRelative}` : normalizedBase;
}
@@ -66,18 +69,6 @@ function isAbsolutePath(input: string): boolean {
return input.startsWith('/') || input.startsWith('\\\\') || /^[a-zA-Z]:[\\/]/.test(input);
}
-function trimTrailingSeparator(input: string): string {
- let end = input.length;
- while (end > 0) {
- const char = input[end - 1];
- if (char !== '/' && char !== '\\') {
- break;
- }
- end--;
- }
- return input.slice(0, end);
-}
-
function normalizeSeparators(input: string, separator: '/' | '\\'): string {
let output = '';
let prevWasSeparator = false;
diff --git a/src/renderer/utils/claudeMdTracker.ts b/src/renderer/utils/claudeMdTracker.ts
index 5510d570..ac045070 100644
--- a/src/renderer/utils/claudeMdTracker.ts
+++ b/src/renderer/utils/claudeMdTracker.ts
@@ -8,8 +8,11 @@
*/
import {
+ isPathPrefix,
lastSeparatorIndex,
+ normalizePathForComparison,
splitPath as splitPathCrossPlatform,
+ stripTrailingSeparators,
} from '@shared/utils/platformPath';
import { extractFileReferences } from './groupTransformer';
@@ -64,7 +67,14 @@ export function getDisplayName(path: string, _source: ClaudeMdSource): string {
* Check if a path is absolute (starts with /).
*/
function isAbsolutePath(path: string): boolean {
- return path.startsWith('/') || path.startsWith('\\\\') || /^[a-zA-Z]:[\\/]/.test(path);
+ return (
+ path.startsWith('/') ||
+ path.startsWith('~/') ||
+ path.startsWith('~\\') ||
+ path === '~' ||
+ path.startsWith('\\\\') ||
+ /^[a-zA-Z]:[\\/]/.test(path)
+ );
}
/**
@@ -82,7 +92,7 @@ function joinPaths(base: string, relative: string): string {
}
// Remove trailing slash from base if present
- const cleanBase = trimTrailingSeparator(base);
+ const cleanBase = stripTrailingSeparators(base);
// Handle @ prefix (file mention marker) - strip it if present
let cleanRelative = relative;
@@ -91,20 +101,21 @@ function joinPaths(base: string, relative: string): string {
}
// Handle ./ prefix (current directory)
- if (cleanRelative.startsWith('./')) {
+ if (cleanRelative.startsWith('./') || cleanRelative.startsWith('.\\')) {
cleanRelative = cleanRelative.slice(2);
}
// Handle ../ prefixes (parent directory)
const separator = cleanBase.includes('\\') ? '\\' : '/';
- const hasUnixRoot = cleanBase.startsWith('/');
- const hasUncRoot = cleanBase.startsWith('\\\\');
+ const hasUncRoot = cleanBase.startsWith('\\\\') || cleanBase.startsWith('//');
+ const hasUnixRoot = !hasUncRoot && cleanBase.startsWith('/');
+ const minRootParts = hasUncRoot ? 2 : 1;
const normalizedRelative = normalizeSeparators(cleanRelative, separator);
const baseParts = splitPath(cleanBase);
let remainingRelative = normalizedRelative;
while (remainingRelative.startsWith(`..${separator}`)) {
remainingRelative = remainingRelative.slice(3);
- if (baseParts.length > 1) {
+ if (baseParts.length > minRootParts) {
baseParts.pop();
}
}
@@ -114,24 +125,12 @@ function joinPaths(base: string, relative: string): string {
if (hasUnixRoot && !normalizedBase.startsWith('/')) {
normalizedBase = `/${normalizedBase}`;
}
- if (hasUncRoot && !normalizedBase.startsWith('\\\\')) {
- normalizedBase = `\\\\${normalizedBase}`;
+ if (hasUncRoot && !normalizedBase.startsWith(`${separator}${separator}`)) {
+ normalizedBase = `${separator}${separator}${normalizedBase}`;
}
return remainingRelative ? `${normalizedBase}${separator}${remainingRelative}` : normalizedBase;
}
-function trimTrailingSeparator(input: string): string {
- let end = input.length;
- while (end > 0) {
- const char = input[end - 1];
- if (char !== '/' && char !== '\\') {
- break;
- }
- end--;
- }
- return input.slice(0, end);
-}
-
function normalizeSeparators(input: string, separator: '/' | '\\'): string {
let output = '';
let prevWasSeparator = false;
@@ -158,7 +157,19 @@ function splitPath(input: string): string[] {
}
function normalizeForComparison(input: string): string {
- return input.replace(/\\/g, '/');
+ return normalizePathForComparison(input);
+}
+
+function createSeenPathSet(paths: string[]): Set {
+ return new Set(paths.map(normalizeForComparison));
+}
+
+function hasSeenPath(seenPaths: Set, path: string): boolean {
+ return seenPaths.has(normalizeForComparison(path));
+}
+
+function rememberPath(seenPaths: Set, path: string): void {
+ seenPaths.add(normalizeForComparison(path));
}
/**
@@ -183,11 +194,7 @@ export function getParentDirectory(dirPath: string): string | null {
* Check if dirPath is at or above stopPath in the directory tree.
*/
function isAtOrAbove(dirPath: string, stopPath: string): boolean {
- const normDir = normalizeForComparison(dirPath).replace(/\/$/, '');
- const normStop = normalizeForComparison(stopPath).replace(/\/$/, '');
-
- // dirPath is at or above stopPath if stopPath starts with dirPath
- return normStop === normDir || normStop.startsWith(normDir + '/');
+ return isPathPrefix(dirPath, stopPath);
}
// =============================================================================
@@ -485,7 +492,7 @@ function computeClaudeMdStats(params: ComputeClaudeMdStatsParams): ClaudeMdStats
} = params;
const newInjections: ClaudeMdInjection[] = [];
- const previousPaths = new Set(previousInjections.map((inj) => inj.path));
+ const previousPaths = createSeenPathSet(previousInjections.map((inj) => inj.path));
// For the first group, add global injections
// Use "ai-N" format for firstSeenInGroup to enable turn navigation in SessionClaudeMdPanel
@@ -493,9 +500,9 @@ function computeClaudeMdStats(params: ComputeClaudeMdStatsParams): ClaudeMdStats
if (isFirstGroup) {
const globalInjections = createGlobalInjections(projectRoot, turnGroupId, tokenData);
for (const injection of globalInjections) {
- if (!previousPaths.has(injection.path)) {
+ if (!hasSeenPath(previousPaths, injection.path)) {
newInjections.push(injection);
- previousPaths.add(injection.path);
+ rememberPath(previousPaths, injection.path);
}
}
}
@@ -526,7 +533,7 @@ function computeClaudeMdStats(params: ComputeClaudeMdStatsParams): ClaudeMdStats
for (const claudeMdPath of claudeMdPaths) {
// Skip if already seen
- if (previousPaths.has(claudeMdPath)) {
+ if (hasSeenPath(previousPaths, claudeMdPath)) {
continue;
}
@@ -546,7 +553,7 @@ function computeClaudeMdStats(params: ComputeClaudeMdStatsParams): ClaudeMdStats
// Create directory injection
const injection = createDirectoryInjection(claudeMdPath, turnGroupId);
newInjections.push(injection);
- previousPaths.add(claudeMdPath);
+ rememberPath(previousPaths, claudeMdPath);
}
}
diff --git a/src/renderer/utils/contextTracker.ts b/src/renderer/utils/contextTracker.ts
index 78eb4986..ec8ecd8e 100644
--- a/src/renderer/utils/contextTracker.ts
+++ b/src/renderer/utils/contextTracker.ts
@@ -9,6 +9,7 @@
* This builds on claudeMdTracker.ts and extends it to track all context sources.
*/
+import { normalizePathForComparison, stripTrailingSeparators } from '@shared/utils/platformPath';
import { estimateTokens } from '@shared/utils/tokenFormatting';
import { MAX_MENTIONED_FILE_TOKENS } from '../types/contextInjection';
@@ -474,7 +475,7 @@ function joinPaths(base: string, relative: string): string {
return relative;
}
- const cleanBase = trimTrailingSeparator(base);
+ const cleanBase = stripTrailingSeparators(base);
// Handle @ prefix (file mention marker) - strip it if present
let cleanRelative = relative;
@@ -483,20 +484,21 @@ function joinPaths(base: string, relative: string): string {
}
// Handle ./ prefix (current directory)
- if (cleanRelative.startsWith('./')) {
+ if (cleanRelative.startsWith('./') || cleanRelative.startsWith('.\\')) {
cleanRelative = cleanRelative.slice(2);
}
// Handle ../ prefixes (parent directory)
const separator = cleanBase.includes('\\') ? '\\' : '/';
- const hasUnixRoot = cleanBase.startsWith('/');
- const hasUncRoot = cleanBase.startsWith('\\\\');
+ const hasUncRoot = cleanBase.startsWith('\\\\') || cleanBase.startsWith('//');
+ const hasUnixRoot = !hasUncRoot && cleanBase.startsWith('/');
+ const minRootParts = hasUncRoot ? 2 : 1;
const normalizedRelative = normalizeSeparators(cleanRelative, separator);
const baseParts = splitPath(cleanBase);
let remainingRelative = normalizedRelative;
while (remainingRelative.startsWith(`..${separator}`)) {
remainingRelative = remainingRelative.slice(3);
- if (baseParts.length > 1) {
+ if (baseParts.length > minRootParts) {
baseParts.pop();
}
}
@@ -506,24 +508,12 @@ function joinPaths(base: string, relative: string): string {
if (hasUnixRoot && !normalizedBase.startsWith('/')) {
normalizedBase = `/${normalizedBase}`;
}
- if (hasUncRoot && !normalizedBase.startsWith('\\\\')) {
- normalizedBase = `\\\\${normalizedBase}`;
+ if (hasUncRoot && !normalizedBase.startsWith(`${separator}${separator}`)) {
+ normalizedBase = `${separator}${separator}${normalizedBase}`;
}
return remainingRelative ? `${normalizedBase}${separator}${remainingRelative}` : normalizedBase;
}
-function trimTrailingSeparator(input: string): string {
- let end = input.length;
- while (end > 0) {
- const char = input[end - 1];
- if (char !== '/' && char !== '\\') {
- break;
- }
- end--;
- }
- return input.slice(0, end);
-}
-
function normalizeSeparators(input: string, separator: '/' | '\\'): string {
let output = '';
let prevWasSeparator = false;
@@ -567,7 +557,50 @@ function splitPath(input: string): string[] {
}
function normalizeForComparison(input: string): string {
- return input.replace(/\\/g, '/');
+ return normalizePathForComparison(input);
+}
+
+function createSeenPathSet(paths: string[]): Set {
+ return new Set(paths.map(normalizeForComparison));
+}
+
+function hasSeenPath(seenPaths: Set, path: string): boolean {
+ return seenPaths.has(normalizeForComparison(path));
+}
+
+function rememberPath(seenPaths: Set, path: string): void {
+ seenPaths.add(normalizeForComparison(path));
+}
+
+function getRecordValueByPath(
+ record: Record | undefined,
+ path: string
+): T | undefined {
+ if (!record) return undefined;
+ const exact = record[path];
+ if (exact !== undefined) return exact;
+
+ const normalizedPath = normalizeForComparison(path);
+ for (const [key, value] of Object.entries(record)) {
+ if (normalizeForComparison(key) === normalizedPath) {
+ return value;
+ }
+ }
+ return undefined;
+}
+
+function getMapValueByPath(map: Map | undefined, path: string): T | undefined {
+ if (!map) return undefined;
+ const exact = map.get(path);
+ if (exact !== undefined) return exact;
+
+ const normalizedPath = normalizeForComparison(path);
+ for (const [key, value] of map.entries()) {
+ if (normalizeForComparison(key) === normalizedPath) {
+ return value;
+ }
+ }
+ return undefined;
}
/**
@@ -604,7 +637,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
} = params;
const newInjections: ContextInjection[] = [];
- const previousPaths = new Set(
+ const previousPaths = createSeenPathSet(
previousInjections
.filter(
(inj): inj is ClaudeMdContextInjection | MentionedFileInjection =>
@@ -620,9 +653,9 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
if (isFirstGroup) {
const globalInjections = createGlobalInjections(projectRoot, turnGroupId, claudeMdTokenData);
for (const injection of globalInjections) {
- if (!previousPaths.has(injection.path)) {
+ if (!hasSeenPath(previousPaths, injection.path)) {
newInjections.push(wrapClaudeMdInjection(injection));
- previousPaths.add(injection.path);
+ rememberPath(previousPaths, injection.path);
}
}
}
@@ -654,7 +687,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
for (const claudeMdPath of claudeMdPaths) {
// Skip if already seen
- if (previousPaths.has(claudeMdPath)) {
+ if (hasSeenPath(previousPaths, claudeMdPath)) {
continue;
}
@@ -674,7 +707,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
// Only include directory CLAUDE.md files that exist (validated via directoryTokenData)
// If directoryTokenData is provided and doesn't contain this path, the file doesn't exist
if (directoryTokenData) {
- const fileInfo = directoryTokenData[claudeMdPath];
+ const fileInfo = getRecordValueByPath(directoryTokenData, claudeMdPath);
if (!fileInfo || !fileInfo.exists || fileInfo.estimatedTokens <= 0) {
// File doesn't exist or has no content - skip it
continue;
@@ -683,12 +716,12 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
const injection = createDirectoryInjection(claudeMdPath, turnGroupId);
injection.estimatedTokens = fileInfo.estimatedTokens;
newInjections.push(wrapClaudeMdInjection(injection));
- previousPaths.add(claudeMdPath);
+ rememberPath(previousPaths, claudeMdPath);
} else {
// Fallback: if no directoryTokenData provided, create with default tokens (legacy behavior)
const injection = createDirectoryInjection(claudeMdPath, turnGroupId);
newInjections.push(wrapClaudeMdInjection(injection));
- previousPaths.add(claudeMdPath);
+ rememberPath(previousPaths, claudeMdPath);
}
}
}
@@ -704,12 +737,12 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
: joinPaths(projectRoot, fileRef.path);
// Skip if already seen
- if (previousPaths.has(absolutePath)) {
+ if (hasSeenPath(previousPaths, absolutePath)) {
continue;
}
// Check if we have token data for this file
- const fileInfo = mentionedFileTokenData?.get(absolutePath);
+ const fileInfo = getMapValueByPath(mentionedFileTokenData, absolutePath);
// Only include files that exist and are under the token limit
if (fileInfo && fileInfo.exists && fileInfo.estimatedTokens <= MAX_MENTIONED_FILE_TOKENS) {
@@ -723,7 +756,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
});
newInjections.push(mentionedFileInjection);
- previousPaths.add(absolutePath);
+ rememberPath(previousPaths, absolutePath);
}
}
}
@@ -736,11 +769,11 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
? fileRef.path
: joinPaths(projectRoot, fileRef.path);
- if (previousPaths.has(absolutePath)) {
+ if (hasSeenPath(previousPaths, absolutePath)) {
continue;
}
- const fileInfo = mentionedFileTokenData?.get(absolutePath);
+ const fileInfo = getMapValueByPath(mentionedFileTokenData, absolutePath);
if (fileInfo && fileInfo.exists && fileInfo.estimatedTokens <= MAX_MENTIONED_FILE_TOKENS) {
const mentionedFileInjection = createMentionedFileInjection({
@@ -753,7 +786,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
});
newInjections.push(mentionedFileInjection);
- previousPaths.add(absolutePath);
+ rememberPath(previousPaths, absolutePath);
}
}
diff --git a/src/renderer/utils/pathDisplay.ts b/src/renderer/utils/pathDisplay.ts
index ccdd6808..a9955671 100644
--- a/src/renderer/utils/pathDisplay.ts
+++ b/src/renderer/utils/pathDisplay.ts
@@ -9,7 +9,7 @@
* Also provides resolveAbsolutePath() for clipboard copy (~ → real home, relative → absolute).
*/
-import { splitPath } from '@shared/utils/platformPath';
+import { getRelativePathWithinPrefix, splitPath } from '@shared/utils/platformPath';
function isWindowsAbsolutePath(input: string): boolean {
return /^[A-Za-z]:[/\\]/.test(input) || input.startsWith('\\\\') || input.startsWith('//');
@@ -38,15 +38,9 @@ export function shortenDisplayPath(fullPath: string, projectRoot?: string, maxLe
// 1. Make relative to project root
if (projectRoot) {
- const root = projectRoot.replace(/[/\\]$/, '');
- const caseInsensitive = isWindowsAbsolutePath(p) || isWindowsAbsolutePath(root);
- const pathForCompare = caseInsensitive ? p.toLowerCase() : p;
- const rootForCompare = caseInsensitive ? root.toLowerCase() : root;
- if (
- pathForCompare.startsWith(rootForCompare + '/') ||
- pathForCompare.startsWith(rootForCompare + '\\')
- ) {
- p = p.slice(root.length + 1);
+ const relativePath = getRelativePathWithinPrefix(projectRoot, p);
+ if (relativePath) {
+ p = relativePath;
}
}
@@ -54,7 +48,7 @@ export function shortenDisplayPath(fullPath: string, projectRoot?: string, maxLe
p = p
.replace(/^\/Users\/[^/]+/, '~')
.replace(/^\/home\/[^/]+/, '~')
- .replace(/^[A-Z]:\\Users\\[^\\]+/i, '~');
+ .replace(/^[A-Z]:[/\\]Users[/\\][^/\\]+/i, '~');
// 3. If short enough, return as-is
if (p.length <= maxLength) return p;
@@ -84,7 +78,7 @@ function inferHomeDir(projectRoot: string): string | null {
const match =
/^(\/Users\/[^/]+)/.exec(projectRoot) ??
/^(\/home\/[^/]+)/.exec(projectRoot) ??
- /^([A-Z]:\\Users\\[^\\]+)/i.exec(projectRoot);
+ /^([A-Z]:[/\\]Users[/\\][^/\\]+)/i.exec(projectRoot);
return match?.[1] ?? null;
}
@@ -122,11 +116,7 @@ export function formatProjectPath(path: string): string {
}
function isWindowsUserPath(input: string): boolean {
- if (input.length < 10) return false;
- const drive = input.charCodeAt(0);
- const hasDriveLetter =
- ((drive >= 65 && drive <= 90) || (drive >= 97 && drive <= 122)) && input[1] === ':';
- return hasDriveLetter && input.slice(2, 9).toLowerCase() === '\\users\\';
+ return /^[A-Z]:[/\\]Users[/\\]/i.test(input);
}
export function resolveAbsolutePath(filePath: string, projectRoot?: string): string {
diff --git a/src/shared/utils/platformPath.ts b/src/shared/utils/platformPath.ts
index 82f0c0aa..03eacb2f 100644
--- a/src/shared/utils/platformPath.ts
+++ b/src/shared/utils/platformPath.ts
@@ -78,6 +78,7 @@ export function joinPath(base: string, ...segments: string[]): string {
export function isPathPrefix(prefix: string, fullPath: string): boolean {
const p = stripTrailingSeparators(normalizePathForComparison(prefix));
const f = stripTrailingSeparators(normalizePathForComparison(fullPath));
+ if (!p) return false;
if (f === p) return true;
// Root prefixes are special: p already ends with "/" ("/" or "c:/").
if (p === '/') return f.startsWith('/');
@@ -85,6 +86,26 @@ export function isPathPrefix(prefix: string, fullPath: string): boolean {
return f.startsWith(p + '/');
}
+/** Return fullPath relative to prefix when fullPath is inside prefix, preserving fullPath style. */
+export function getRelativePathWithinPrefix(prefix: string, fullPath: string): string | null {
+ const cleanPrefix = stripTrailingSeparators(prefix);
+ if (!isPathPrefix(cleanPrefix, fullPath)) {
+ return null;
+ }
+
+ const normalizedPrefix = stripTrailingSeparators(normalizePathForComparison(cleanPrefix));
+ const normalizedFullPath = stripTrailingSeparators(normalizePathForComparison(fullPath));
+ if (normalizedFullPath === normalizedPrefix) {
+ return '';
+ }
+
+ let relativePath = fullPath.slice(cleanPrefix.length);
+ while (relativePath.startsWith('/') || relativePath.startsWith('\\')) {
+ relativePath = relativePath.slice(1);
+ }
+ return relativePath;
+}
+
/** Get the last segment (filename) from a path. */
export function getBasename(filePath: string): string {
const parts = splitPath(filePath);
diff --git a/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts b/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts
index 147ae8b6..98ddb38f 100644
--- a/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts
+++ b/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts
@@ -129,6 +129,20 @@ describe('recentProjectOpenHistory', () => {
).toBe(0);
});
+ it('collapses Windows drive-case and separator variants', () => {
+ recordRecentProjectOpenPaths(['C:\\Work\\Repo'], 5_000);
+ recordRecentProjectOpenPaths(['c:/work/repo/'], 8_000);
+
+ expect(
+ getRecentProjectLastOpenedAt(
+ makeProject({
+ primaryPath: 'C:/WORK/REPO',
+ associatedPaths: ['C:/WORK/REPO'],
+ })
+ )
+ ).toBe(8_000);
+ });
+
it('does not record generated ephemeral project paths', () => {
recordRecentProjectOpenPaths(
['/private/var/folders/7b/cache/T/codex-agent-teams-appstyle-zudek6i9', '/workspace/opened'],
@@ -152,4 +166,9 @@ describe('recentProjectOpenHistory', () => {
)
).toBe(10_000);
});
+
+ it('ignores blank project paths without throwing', () => {
+ expect(() => recordRecentProjectOpenPaths([' '], 10_000)).not.toThrow();
+ expect(getRecentProjectLastOpenedAt(makeProject({ primaryPath: ' ', associatedPaths: [' '] }))).toBe(0);
+ });
});
diff --git a/test/main/build/electronBuilderDistScript.test.ts b/test/main/build/electronBuilderDistScript.test.ts
index dcb80d48..79ae4377 100644
--- a/test/main/build/electronBuilderDistScript.test.ts
+++ b/test/main/build/electronBuilderDistScript.test.ts
@@ -1,14 +1,10 @@
// @vitest-environment node
-import { pathToFileURL } from 'node:url';
-
import { describe, expect, it } from 'vitest';
-const scriptUrl = pathToFileURL(`${process.cwd()}/scripts/electron-builder/dist.mjs`).href;
+const { buildElectronBuilderInvocations } = require('../../../scripts/electron-builder/dist-invocations.cjs');
describe('electron-builder dist wrapper', () => {
it('splits multi-platform builds so Linux-only package name overrides do not affect macOS or Windows', async () => {
- const { buildElectronBuilderInvocations } = await import(scriptUrl);
-
expect(
buildElectronBuilderInvocations(['--mac', '--win', '--linux', '--publish', 'never'])
).toEqual([
@@ -27,8 +23,6 @@ describe('electron-builder dist wrapper', () => {
});
it('adds the filesystem-safe package name override to Linux-only builds', async () => {
- const { buildElectronBuilderInvocations } = await import(scriptUrl);
-
expect(buildElectronBuilderInvocations(['--linux', '--publish', 'never'])).toEqual([
{
args: [
@@ -43,8 +37,6 @@ describe('electron-builder dist wrapper', () => {
});
it('leaves macOS arch-specific builds unchanged', async () => {
- const { buildElectronBuilderInvocations } = await import(scriptUrl);
-
expect(buildElectronBuilderInvocations(['--mac', '--arm64', '--publish', 'never'])).toEqual([
{ args: ['--mac', '--arm64', '--publish', 'never'] },
]);
diff --git a/test/main/services/editor/FileSearchService.test.ts b/test/main/services/editor/FileSearchService.test.ts
index f9fa4d45..956580d2 100644
--- a/test/main/services/editor/FileSearchService.test.ts
+++ b/test/main/services/editor/FileSearchService.test.ts
@@ -6,6 +6,7 @@ import * as path from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('fs/promises', () => ({
+ access: vi.fn(),
readdir: vi.fn(),
readFile: vi.fn(),
stat: vi.fn(),
@@ -36,6 +37,7 @@ describe('FileSearchService', () => {
beforeEach(() => {
vi.resetAllMocks();
+ vi.mocked(fs.access).mockRejectedValue(new Error('not a git repository') as never);
service = new FileSearchService();
});
@@ -173,4 +175,27 @@ describe('FileSearchService', () => {
expect(result.results[0].matches[1].column).toBe(4);
expect(result.results[0].matches[2].column).toBe(8);
});
+
+ it('returns slash-normalized relative paths for quick open on Windows', async () => {
+ vi.mocked(fs.readdir).mockImplementation(async (dirPath: unknown) => {
+ const normalized = path.normalize(String(dirPath));
+ if (normalized === path.normalize(PROJECT_ROOT)) {
+ return [{ name: 'src', isFile: () => false, isDirectory: () => true }] as never;
+ }
+ if (normalized === path.normalize(path.join(PROJECT_ROOT, 'src'))) {
+ return [{ name: 'app.ts', isFile: () => true, isDirectory: () => false }] as never;
+ }
+ return [] as never;
+ });
+
+ const files = await service.listFiles(PROJECT_ROOT);
+
+ expect(files).toEqual([
+ {
+ path: path.join(PROJECT_ROOT, 'src', 'app.ts'),
+ name: 'app.ts',
+ relativePath: 'src/app.ts',
+ },
+ ]);
+ });
});
diff --git a/test/renderer/components/chat/SessionContextPanel/buildDirectoryTree.test.ts b/test/renderer/components/chat/SessionContextPanel/buildDirectoryTree.test.ts
new file mode 100644
index 00000000..bd7e1205
--- /dev/null
+++ b/test/renderer/components/chat/SessionContextPanel/buildDirectoryTree.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildDirectoryTree } from '@renderer/components/chat/SessionContextPanel/DirectoryTree/buildDirectoryTree';
+
+import type { ClaudeMdContextInjection } from '@renderer/types/contextInjection';
+
+function injection(path: string): ClaudeMdContextInjection {
+ return {
+ id: path,
+ category: 'claude-md',
+ path,
+ source: 'directory',
+ displayName: 'CLAUDE.md',
+ isGlobal: false,
+ estimatedTokens: 12,
+ firstSeenInGroup: 'ai-0',
+ };
+}
+
+describe('buildDirectoryTree Windows paths', () => {
+ it('strips project root case-insensitively for Windows paths with mixed separators', () => {
+ const root = buildDirectoryTree(
+ [injection('c:\\Users\\Alice\\repo\\src\\CLAUDE.md')],
+ 'C:/Users/Alice/Repo'
+ );
+
+ expect(root.children.has('c:')).toBe(false);
+ expect(root.children.get('src')?.children.get('CLAUDE.md')?.path).toBe(
+ 'c:\\Users\\Alice\\repo\\src\\CLAUDE.md'
+ );
+ });
+
+ it('does not strip sibling paths that only share a prefix', () => {
+ const root = buildDirectoryTree(
+ [injection('C:\\Users\\Alice\\Repo2\\CLAUDE.md')],
+ 'C:\\Users\\Alice\\Repo'
+ );
+
+ expect(root.children.get('C:')?.children.get('Users')).toBeDefined();
+ });
+
+ it('falls back to the injection path when project root is empty', () => {
+ const root = buildDirectoryTree([injection('C:\\Users\\Alice\\Repo\\CLAUDE.md')], '');
+
+ expect(root.children.get('C:')?.children.get('Users')).toBeDefined();
+ });
+});
diff --git a/test/renderer/components/chat/viewers/MarkdownViewer.test.tsx b/test/renderer/components/chat/viewers/MarkdownViewer.test.tsx
index 2ed11137..42c8218d 100644
--- a/test/renderer/components/chat/viewers/MarkdownViewer.test.tsx
+++ b/test/renderer/components/chat/viewers/MarkdownViewer.test.tsx
@@ -40,7 +40,10 @@ vi.mock('@renderer/components/chat/viewers/FileLink', () => ({
isRelativeUrl: () => false,
}));
-import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
+import {
+ MarkdownViewer,
+ resolveRelativePath,
+} from '@renderer/components/chat/viewers/MarkdownViewer';
describe('MarkdownViewer code blocks', () => {
afterEach(() => {
@@ -89,3 +92,23 @@ describe('MarkdownViewer code blocks', () => {
});
});
});
+
+describe('MarkdownViewer local image path resolution', () => {
+ it('resolves Windows relative image paths against a Windows base directory', () => {
+ expect(resolveRelativePath('.\\images\\plot.png', 'C:\\Repo\\docs')).toBe(
+ 'C:\\Repo\\docs\\images\\plot.png'
+ );
+ });
+
+ it('preserves absolute Windows image paths', () => {
+ expect(resolveRelativePath('C:\\Screens\\plot.png', 'C:\\Repo\\docs')).toBe(
+ 'C:\\Screens\\plot.png'
+ );
+ });
+
+ it('resolves Windows UNC parent image paths without escaping the share root', () => {
+ expect(resolveRelativePath('..\\assets\\plot.png', '\\\\server\\share\\repo\\docs')).toBe(
+ '\\\\server\\share\\repo\\assets\\plot.png'
+ );
+ });
+});
diff --git a/test/renderer/components/fileLink.test.ts b/test/renderer/components/fileLink.test.ts
index 851b3bd2..94edc29a 100644
--- a/test/renderer/components/fileLink.test.ts
+++ b/test/renderer/components/fileLink.test.ts
@@ -121,4 +121,19 @@ describe('resolveFileLinkPath', () => {
resolveFileLinkPath('/Users/belief/dev/projects/your_posts/docs/roadmap.md', PROJECT_PATH)
).toBe('/Users/belief/dev/projects/your_posts/docs/roadmap.md');
});
+
+ it('preserves Windows UNC roots when normalizing path segments', () => {
+ expect(resolveFileLinkPath('\\\\server\\share\\repo\\..\\docs\\roadmap.md', PROJECT_PATH)).toBe(
+ '\\\\server\\share\\docs\\roadmap.md'
+ );
+ expect(resolveFileLinkPath('//server/share/repo/../docs/roadmap.md', PROJECT_PATH)).toBe(
+ '//server/share/docs/roadmap.md'
+ );
+ });
+
+ it('preserves Windows drive roots when normalizing dot segments', () => {
+ expect(resolveFileLinkPath('C:\\repo\\src\\..\\README.md', PROJECT_PATH)).toBe(
+ 'C:\\repo\\README.md'
+ );
+ });
});
diff --git a/test/renderer/hooks/useFileSuggestions.test.ts b/test/renderer/hooks/useFileSuggestions.test.ts
index 1f916937..1b9744cf 100644
--- a/test/renderer/hooks/useFileSuggestions.test.ts
+++ b/test/renderer/hooks/useFileSuggestions.test.ts
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';
-import { filterFileSuggestions, formatFileMentionPath } from '@renderer/hooks/useFileSuggestions';
+import {
+ extractDirectories,
+ filterFileSuggestions,
+ filterFolderSuggestions,
+ formatFileMentionPath,
+} from '@renderer/hooks/useFileSuggestions';
import type { QuickOpenFile } from '@shared/types/editor';
@@ -91,14 +96,42 @@ describe('filterFileSuggestions', () => {
expect(results.map((r) => r.name)).toEqual(['auth.ts', 'database.ts']);
});
+ it('matches Windows backslash queries against normalized relative paths', () => {
+ const results = filterFileSuggestions(FILES, 'services\\auth');
+ expect(results).toHaveLength(1);
+ expect(results[0].relativePath).toBe('src/services/auth.ts');
+ });
+
it('returns results in file list order', () => {
const results = filterFileSuggestions(FILES, '.ts');
expect(results[0].name).toBe('index.ts');
});
it('quotes inserted paths that contain spaces', () => {
- expect(formatFileMentionPath('src/My Component/App.tsx')).toBe(
- '"src/My Component/App.tsx"'
- );
+ expect(formatFileMentionPath('src/My Component/App.tsx')).toBe('"src/My Component/App.tsx"');
+ });
+});
+
+describe('folder suggestions', () => {
+ it('derives stable folders from Windows relative paths', () => {
+ const folders = extractDirectories(
+ [
+ {
+ name: 'auth.ts',
+ relativePath: 'src\\services\\auth.ts',
+ path: 'C:\\Repo\\src\\services\\auth.ts',
+ },
+ ],
+ 'C:\\Repo'
+ );
+
+ expect(folders.map((f) => [f.name, f.relativePath, f.absolutePath])).toEqual([
+ ['src', 'src/', 'C:\\Repo\\src'],
+ ['services', 'src/services/', 'C:\\Repo\\src\\services'],
+ ]);
+
+ const suggestions = filterFolderSuggestions(folders, 'src\\services\\');
+ expect(suggestions).toHaveLength(1);
+ expect(suggestions[0].insertText).toBe('src/services/');
});
});
diff --git a/test/renderer/store/pathResolution.test.ts b/test/renderer/store/pathResolution.test.ts
index 19de8fad..218390dc 100644
--- a/test/renderer/store/pathResolution.test.ts
+++ b/test/renderer/store/pathResolution.test.ts
@@ -13,6 +13,7 @@ describe('resolveFilePath', () => {
it('resolves dot-prefixed relative paths', () => {
expect(resolveFilePath('/repo', './src/app.ts')).toBe('/repo/src/app.ts');
+ expect(resolveFilePath('C:\\repo', '.\\src\\app.ts')).toBe('C:\\repo\\src\\app.ts');
});
it('resolves parent relative paths on unix', () => {
@@ -27,6 +28,11 @@ describe('resolveFilePath', () => {
);
});
+ it('preserves Windows drive-root separators when resolving child paths', () => {
+ expect(resolveFilePath('C:\\', 'src\\app.ts')).toBe('C:\\src\\app.ts');
+ expect(resolveFilePath('C:/', 'src/app.ts')).toBe('C:/src/app.ts');
+ });
+
it('passes through tilde paths as-is', () => {
expect(resolveFilePath('/repo', '~/some/directory')).toBe('~/some/directory');
});
@@ -45,6 +51,24 @@ describe('resolveFilePath', () => {
);
});
+ it('resolves relative paths under Windows UNC roots', () => {
+ expect(resolveFilePath('\\\\server\\share\\repo', 'src\\index.ts')).toBe(
+ '\\\\server\\share\\repo\\src\\index.ts'
+ );
+ expect(resolveFilePath('//server/share/repo', 'src/index.ts')).toBe(
+ '//server/share/repo/src/index.ts'
+ );
+ });
+
+ it('does not resolve parent paths above a Windows UNC share root', () => {
+ expect(resolveFilePath('\\\\server\\share', '..\\outside\\file.ts')).toBe(
+ '\\\\server\\share\\outside\\file.ts'
+ );
+ expect(resolveFilePath('//server/share', '../outside/file.ts')).toBe(
+ '//server/share/outside/file.ts'
+ );
+ });
+
it('does not treat tilde in the middle as special', () => {
expect(resolveFilePath('/repo', 'foo~/bar')).toBe('/repo/foo~/bar');
});
diff --git a/test/renderer/utils/claudeMdTracker.test.ts b/test/renderer/utils/claudeMdTracker.test.ts
index 6e23567e..32f2199d 100644
--- a/test/renderer/utils/claudeMdTracker.test.ts
+++ b/test/renderer/utils/claudeMdTracker.test.ts
@@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest';
import {
detectClaudeMdFromFilePath,
+ extractUserMentionPaths,
getDirectory,
getParentDirectory,
+ processSessionClaudeMd,
} from '@renderer/utils/claudeMdTracker';
describe('claudeMdTracker path helpers', () => {
@@ -67,6 +69,13 @@ describe('claudeMdTracker path helpers', () => {
expect(result).toHaveLength(2);
});
+ it('detects CLAUDE.md files for Windows paths with drive-case and separator differences', () => {
+ const result = detectClaudeMdFromFilePath('c:\\Repo\\src\\file.ts', 'C:/repo');
+ expect(result).toContain('c:\\Repo\\src\\CLAUDE.md');
+ expect(result).toContain('c:\\Repo\\CLAUDE.md');
+ expect(result).toHaveLength(2);
+ });
+
it('uses correct separator for generated paths', () => {
const unixResult = detectClaudeMdFromFilePath('/repo/src/file.ts', '/repo');
for (const p of unixResult) {
@@ -93,3 +102,97 @@ describe('claudeMdTracker path helpers', () => {
});
});
});
+
+describe('processSessionClaudeMd Windows paths', () => {
+ function aiReadGroup(id: string, turnIndex: number, filePath: string) {
+ return {
+ id,
+ turnIndex,
+ startTime: new Date(0),
+ endTime: new Date(0),
+ durationMs: 0,
+ steps: [
+ {
+ type: 'tool_call',
+ content: {
+ toolName: 'Read',
+ toolInput: { file_path: filePath },
+ },
+ },
+ ],
+ tokens: { input: 1000, output: 0, cached: 0 },
+ summary: {
+ toolCallCount: 1,
+ outputMessageCount: 0,
+ subagentCount: 0,
+ totalDurationMs: 0,
+ totalTokens: 1000,
+ outputTokens: 0,
+ cachedTokens: 0,
+ },
+ status: 'complete',
+ processes: [],
+ chunkId: id,
+ metrics: {},
+ responses: [],
+ } as any;
+ }
+
+ it('dedupes directory CLAUDE.md paths across Windows case and separator differences', () => {
+ const stats = processSessionClaudeMd(
+ [
+ { type: 'ai', group: aiReadGroup('ai-0', 0, 'C:\\Repo\\src\\file.ts') },
+ { type: 'ai', group: aiReadGroup('ai-1', 1, 'c:/repo/src/other.ts') },
+ ],
+ 'C:\\Repo'
+ );
+
+ const firstDirectories = stats
+ .get('ai-0')!
+ .newInjections.filter((injection) => injection.source === 'directory')
+ .map((injection) => injection.path);
+ const secondDirectories = stats
+ .get('ai-1')!
+ .newInjections.filter((injection) => injection.source === 'directory');
+
+ expect(firstDirectories).toEqual(['C:\\Repo\\src\\CLAUDE.md']);
+ expect(secondDirectories).toEqual([]);
+ });
+});
+
+describe('extractUserMentionPaths Windows paths', () => {
+ function userGroupWithPath(path: string) {
+ return {
+ content: {
+ fileReferences: [{ path, raw: `@${path}` }],
+ },
+ } as any;
+ }
+
+ it('resolves Windows current-directory mentions with backslash separators', () => {
+ expect(extractUserMentionPaths(userGroupWithPath('.\\src\\app.ts'), 'C:\\Repo')).toEqual([
+ 'C:\\Repo\\src\\app.ts',
+ ]);
+ });
+
+ it('preserves Windows drive-root separators for relative mentions', () => {
+ expect(extractUserMentionPaths(userGroupWithPath('src\\app.ts'), 'C:\\')).toEqual([
+ 'C:\\src\\app.ts',
+ ]);
+ });
+
+ it('resolves relative mentions under UNC roots without escaping the share root', () => {
+ expect(
+ extractUserMentionPaths(userGroupWithPath('../outside/file.ts'), '//server/share')
+ ).toEqual(['//server/share/outside/file.ts']);
+ expect(
+ extractUserMentionPaths(userGroupWithPath('..\\outside\\file.ts'), '\\\\server\\share')
+ ).toEqual(['\\\\server\\share\\outside\\file.ts']);
+ });
+
+ it('leaves home-relative mentions untouched', () => {
+ expect(extractUserMentionPaths(userGroupWithPath('~\\.claude\\CLAUDE.md'), 'C:\\Repo')).toEqual(
+ ['~\\.claude\\CLAUDE.md']
+ );
+ });
+});
diff --git a/test/renderer/utils/contextTracker.test.ts b/test/renderer/utils/contextTracker.test.ts
new file mode 100644
index 00000000..ae2a04c6
--- /dev/null
+++ b/test/renderer/utils/contextTracker.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from 'vitest';
+
+import { processSessionContextWithPhases } from '@renderer/utils/contextTracker';
+
+function aiReadGroup(id: string, turnIndex: number, filePath: string) {
+ return {
+ id,
+ turnIndex,
+ startTime: new Date(0),
+ endTime: new Date(0),
+ durationMs: 0,
+ steps: [
+ {
+ type: 'tool_call',
+ content: {
+ toolName: 'Read',
+ toolInput: { file_path: filePath },
+ },
+ },
+ ],
+ tokens: { input: 1000, output: 0, cached: 0 },
+ summary: {
+ toolCallCount: 1,
+ outputMessageCount: 0,
+ subagentCount: 0,
+ totalDurationMs: 0,
+ totalTokens: 1000,
+ outputTokens: 0,
+ cachedTokens: 0,
+ },
+ status: 'complete',
+ processes: [],
+ chunkId: id,
+ metrics: {},
+ responses: [],
+ linkedTools: new Map(),
+ displayItems: [],
+ } as any;
+}
+
+describe('processSessionContextWithPhases Windows paths', () => {
+ it('matches validated directory CLAUDE.md data across drive-case and separator differences', () => {
+ const { statsMap } = processSessionContextWithPhases(
+ [{ type: 'ai', group: aiReadGroup('ai-0', 0, 'c:/repo/src/file.ts') }],
+ 'C:\\Repo',
+ undefined,
+ undefined,
+ {
+ 'C:\\Repo\\src\\CLAUDE.md': {
+ path: 'C:\\Repo\\src\\CLAUDE.md',
+ exists: true,
+ charCount: 492,
+ estimatedTokens: 123,
+ },
+ }
+ );
+
+ const directoryInjection = statsMap
+ .get('ai-0')!
+ .newInjections.find(
+ (injection) => injection.category === 'claude-md' && injection.source === 'directory'
+ );
+
+ expect(directoryInjection?.estimatedTokens).toBe(123);
+ });
+});
diff --git a/test/renderer/utils/pathDisplay.test.ts b/test/renderer/utils/pathDisplay.test.ts
index 20c5976b..f476bf16 100644
--- a/test/renderer/utils/pathDisplay.test.ts
+++ b/test/renderer/utils/pathDisplay.test.ts
@@ -8,25 +8,44 @@ import {
describe('pathDisplay Windows paths', () => {
it('treats lowercase drive paths as absolute', () => {
- expect(resolveAbsolutePath('c:\\Users\\Alice\\repo\\src\\app.ts', 'C:\\Users\\Alice\\repo')).toBe(
- 'c:\\Users\\Alice\\repo\\src\\app.ts'
- );
+ expect(
+ resolveAbsolutePath('c:\\Users\\Alice\\repo\\src\\app.ts', 'C:\\Users\\Alice\\repo')
+ ).toBe('c:\\Users\\Alice\\repo\\src\\app.ts');
});
it('shortens project-root relative paths case-insensitively on Windows', () => {
- expect(shortenDisplayPath('c:\\Users\\Alice\\repo\\src\\app.ts', 'C:\\Users\\Alice\\Repo')).toBe(
+ expect(
+ shortenDisplayPath('c:\\Users\\Alice\\repo\\src\\app.ts', 'C:\\Users\\Alice\\Repo')
+ ).toBe('src\\app.ts');
+ });
+
+ it('shortens mixed-separator Windows paths without treating siblings as children', () => {
+ expect(shortenDisplayPath('c:\\Users\\Alice\\Repo\\src\\app.ts', 'C:/Users/Alice/repo')).toBe(
'src\\app.ts'
);
+ expect(
+ shortenDisplayPath('C:\\Users\\Alice\\Repo2\\src\\app.ts', 'C:\\Users\\Alice\\Repo')
+ ).toBe('~\\Repo2\\src\\app.ts');
});
it('formats lowercase Windows user paths with a home marker', () => {
expect(formatProjectPath('c:\\users\\Alice\\repo')).toBe('~/repo');
+ expect(formatProjectPath('C:/Users/Alice/repo')).toBe('~/repo');
});
it('resolves home paths from lowercase Windows user roots', () => {
expect(resolveAbsolutePath('~/repo/src/app.ts', 'c:\\users\\Alice\\workspace')).toBe(
'c:\\users\\Alice\\repo\\src\\app.ts'
);
+ expect(resolveAbsolutePath('~/repo/src/app.ts', 'C:/Users/Alice/workspace')).toBe(
+ 'C:/Users/Alice/repo/src/app.ts'
+ );
+ });
+
+ it('shortens forward-slash Windows user paths with a home marker', () => {
+ expect(shortenDisplayPath('C:/Users/Alice/repo/src/app.ts', undefined, 80)).toBe(
+ '~/repo/src/app.ts'
+ );
});
it('resolves relative paths using the project root separator', () => {
diff --git a/test/shared/utils/platformPath.test.ts b/test/shared/utils/platformPath.test.ts
new file mode 100644
index 00000000..19d168cc
--- /dev/null
+++ b/test/shared/utils/platformPath.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest';
+
+import { getRelativePathWithinPrefix, isPathPrefix } from '../../../src/shared/utils/platformPath';
+
+describe('platformPath Windows containment', () => {
+ it('matches Windows drive paths case-insensitively and preserves child path style', () => {
+ expect(isPathPrefix('C:/Users/Alice/Repo', 'c:\\Users\\Alice\\repo\\src\\app.ts')).toBe(true);
+ expect(
+ getRelativePathWithinPrefix('C:/Users/Alice/Repo', 'c:\\Users\\Alice\\repo\\src\\app.ts')
+ ).toBe('src\\app.ts');
+ });
+
+ it('matches UNC paths with mixed separators', () => {
+ expect(
+ getRelativePathWithinPrefix('\\\\server\\share\\Repo', '//server/share/repo/src/app.ts')
+ ).toBe('src/app.ts');
+ });
+
+ it('rejects sibling paths that only share the same text prefix', () => {
+ expect(
+ getRelativePathWithinPrefix('C:\\Users\\Alice\\Repo', 'C:\\Users\\Alice\\Repo2\\x.ts')
+ ).toBe(null);
+ });
+
+ it('keeps POSIX paths case-sensitive', () => {
+ expect(getRelativePathWithinPrefix('/Users/Alice/Repo', '/Users/Alice/Repo/src/app.ts')).toBe(
+ 'src/app.ts'
+ );
+ expect(getRelativePathWithinPrefix('/Users/Alice/Repo', '/Users/Alice/repo/src/app.ts')).toBe(
+ null
+ );
+ });
+
+ it('does not treat an empty prefix as the root of absolute paths', () => {
+ expect(isPathPrefix('', '/Users/Alice/Repo/src/app.ts')).toBe(false);
+ expect(getRelativePathWithinPrefix('', '/Users/Alice/Repo/src/app.ts')).toBe(null);
+ });
+});
From cb97b97e75ebf96281ce4ee58d7a63f281da16d4 Mon Sep 17 00:00:00 2001
From: 777genius
Date: Sat, 16 May 2026 17:43:25 +0300
Subject: [PATCH 8/9] fix: align project path option metadata
---
src/renderer/components/team/dialogs/projectPathOptions.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/renderer/components/team/dialogs/projectPathOptions.ts b/src/renderer/components/team/dialogs/projectPathOptions.ts
index 6cdae4b0..496ca3e9 100644
--- a/src/renderer/components/team/dialogs/projectPathOptions.ts
+++ b/src/renderer/components/team/dialogs/projectPathOptions.ts
@@ -13,7 +13,7 @@ export interface ProjectPathProject extends Project {
filesystemState?: DashboardRecentProjectFilesystemState;
}
-export interface ProjectPathOptionMeta {
+export interface ProjectPathOptionMeta extends Record {
discoverySource?: DashboardRecentProjectSource;
filesystemState?: DashboardRecentProjectFilesystemState;
}
From bfebdff3cf619fd3d1f1e01cead8a9e80ec0e5a2 Mon Sep 17 00:00:00 2001
From: 777genius
Date: Sat, 16 May 2026 20:03:30 +0300
Subject: [PATCH 9/9] chore: save local dev updates
---
README.md | 4 +-
...gastown-paperclip-comparison-2026-05-16.md | 107 ++++++++++++++++++
docs/research/real-competitors-comparison.md | 2 +-
package.json | 6 +-
patches/@radix-ui__react-presence@1.1.5.patch | 86 ++++++++++++++
pnpm-lock.yaml | 26 +++--
.../components/dashboard/CliStatusBanner.tsx | 5 +-
.../providerDashboardRateLimits.test.ts | 34 ++++++
.../dashboard/providerDashboardRateLimits.ts | 6 +
.../team/dialogs/projectPathOptions.ts | 2 +-
.../components/ui/presence.react19.test.tsx | 72 ++++++++++++
11 files changed, 335 insertions(+), 15 deletions(-)
create mode 100644 docs/research/gastown-paperclip-comparison-2026-05-16.md
create mode 100644 patches/@radix-ui__react-presence@1.1.5.patch
create mode 100644 src/renderer/components/ui/presence.react19.test.tsx
diff --git a/README.md b/README.md
index 678494d5..40eb7624 100644
--- a/README.md
+++ b/README.md
@@ -191,10 +191,10 @@ For feature architecture and implementation guidance:
| **Git worktree isolation** | ✅ Optional | ✅ Core primitive | ✅ Worktrees / branches | ⚠️ Background branches/VMs | ⚠️ Manual worktrees |
| **Multi-agent backend** | ✅ Claude, Codex + OpenCode teammates | ✅ Claude, Codex, Gemini, Copilot + more | ✅ BYO agents: Claude, Codex, Cursor/OpenCode, HTTP | ⚠️ Multi-model agents, no team backend | ⚠️ Claude-only experimental teams |
| **Org chart / governance** | ⚠️ Roles + approvals, no org chart | ⚠️ Roles + escalation | ✅ Org chart + board governance | ⚠️ Team admin only | ❌ |
-| **Budget controls** | ⚠️ Cost/token visibility, no hard caps | ⚠️ Cost tiers + digest, no hard caps | ✅ Per-agent budgets + hard stops | ⚠️ Usage + BG spend limits | ⚠️ `/cost` + workspace limits |
+| **Budget controls** | ⚠️ Cost/token visibility, no hard caps | ⚠️ Cost tiers + digest, no hard caps | ✅ Per-agent budgets + hard stops | ⚠️ Usage + BG spend limits | ⚠️ `/usage` + workspace limits |
| **Price** | **Free OSS UI**, provider access needed | Free OSS, runtime plans needed | Free OSS, self-hosted + infra | Free + paid usage | Claude plan or API usage |
-Fact sources checked on May 5, 2026: [detailed research notes](docs/research/gastown-paperclip-comparison-2026-05-05.md), [Gastown README](https://github.com/gastownhall/gastown), [Gastown provider guide](https://github.com/gastownhall/gastown/blob/main/docs/agent-provider-integration.md), [Gastown scheduler](https://github.com/gastownhall/gastown/blob/main/docs/design/scheduler.md), [Paperclip README](https://github.com/paperclipai/paperclip), [Paperclip adapters](https://github.com/paperclipai/paperclip/blob/master/docs/adapters/overview.md), [Paperclip budgets](https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/costs-and-budgets.md), [Paperclip runtime services](https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/execution-workspaces-and-runtime-services.md), [Paperclip Kanban source](https://github.com/paperclipai/paperclip/blob/master/ui/src/components/KanbanBoard.tsx), [Cursor Background Agents](https://docs.cursor.com/en/background-agents), [Cursor Diffs & Review](https://docs.cursor.com/en/agent/review), [Cursor Bugbot](https://docs.cursor.com/en/bugbot), [Cursor pricing](https://docs.cursor.com/en/account/usage), [Claude Code agent teams](https://code.claude.com/docs/en/agent-teams), [Claude Code subagents](https://code.claude.com/docs/en/sub-agents), [Claude Code workflows](https://code.claude.com/docs/en/common-workflows), [Claude Code costs](https://code.claude.com/docs/en/costs), [Claude pricing](https://claude.com/pricing).
+Fact sources checked on May 16, 2026: [detailed research notes](docs/research/gastown-paperclip-comparison-2026-05-16.md), [Gastown README](https://github.com/gastownhall/gastown), [Gastown provider guide](https://github.com/gastownhall/gastown/blob/main/docs/agent-provider-integration.md), [Gastown scheduler](https://github.com/gastownhall/gastown/blob/main/docs/design/scheduler.md), [Gastown release](https://github.com/gastownhall/gastown/releases/tag/v1.1.0), [Paperclip README](https://github.com/paperclipai/paperclip), [Paperclip adapters](https://github.com/paperclipai/paperclip/blob/master/docs/adapters/overview.md), [Paperclip budgets](https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/costs-and-budgets.md), [Paperclip runtime services](https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/execution-workspaces-and-runtime-services.md), [Paperclip Kanban source](https://github.com/paperclipai/paperclip/blob/master/ui/src/components/KanbanBoard.tsx), [Paperclip work products](https://github.com/paperclipai/paperclip/blob/master/packages/shared/src/validators/work-product.ts), [Paperclip release](https://github.com/paperclipai/paperclip/releases/tag/v2026.513.0), [Cursor Background Agents](https://docs.cursor.com/en/background-agents), [Cursor Diffs & Review](https://docs.cursor.com/en/agent/review), [Cursor Bugbot](https://docs.cursor.com/en/bugbot), [Cursor pricing](https://docs.cursor.com/en/account/usage), [Claude Code agent teams](https://code.claude.com/docs/en/agent-teams), [Claude Code subagents](https://code.claude.com/docs/en/sub-agents), [Claude Code workflows](https://code.claude.com/docs/en/common-workflows), [Claude Code costs](https://code.claude.com/docs/en/costs), [Claude pricing](https://claude.com/pricing).
---
diff --git a/docs/research/gastown-paperclip-comparison-2026-05-16.md b/docs/research/gastown-paperclip-comparison-2026-05-16.md
new file mode 100644
index 00000000..e8762e10
--- /dev/null
+++ b/docs/research/gastown-paperclip-comparison-2026-05-16.md
@@ -0,0 +1,107 @@
+# Gastown и Paperclip comparison для лендинга и README
+
+> Дата проверки: 2026-05-16
+> Цель: публичная таблица `Agent Teams | Gastown | Paperclip | Cursor | Claude Code CLI` без угадываний по конкурентам.
+> Метод: `gh repo view`, `gh api` по первичным GitHub-файлам, официальные docs Cursor и Claude Code, страница Claude pricing.
+
+## Snapshot
+
+| Проект | Позиционирование | Статус на 2026-05-16 | Лицензия |
+|---|---|---:|---|
+| **Gastown** | multi-agent workspace manager для coding agents | `15,228★`, latest `v1.1.0` от `2026-05-07`, push `2026-05-15` | MIT |
+| **Paperclip** | control plane для autonomous AI companies | `65,796★`, latest `v2026.513.0` от `2026-05-13`, push `2026-05-16` | MIT |
+
+## Что изменилось после проверки 2026-05-05
+
+- **Gastown**: свежий GitHub snapshot изменился с `v1.0.1` на `v1.1.0`; README/provider/scheduler факты для публичной таблицы остались валидными.
+- **Paperclip**: свежий GitHub snapshot изменился с `v2026.428.0` на `v2026.513.0`; README/adapters/budget/runtime/Kanban facts остались валидными.
+- **Claude Code costs**: официальный cost guide теперь называет `/usage` как команду для session token/cost tracking. Поэтому публичная строка `Budget controls` для Claude Code CLI обновлена с `/cost + workspace limits` на `/usage + workspace limits`.
+- **Claude pricing**: Team pricing page явно включает Claude Code в Team seats; публичная строка `Claude plan or API usage` остаётся корректной.
+- **Cursor**: official docs по Background Agents, Diffs & Review, Bugbot и usage/pricing по-прежнему поддерживают текущие формулировки таблицы. Background Agents остаются remote/async agents on separate branches/VMs with auto-run terminal commands; Bugbot остаётся PR-review product with its own pricing.
+
+## Проверенные публичные формулировки
+
+### Gastown
+
+- README по-прежнему позиционирует Gas Town как workspace manager для Claude Code, GitHub Copilot, Codex, Gemini и других coding agents.
+- Provider guide по-прежнему описывает tmux/provider contract для Claude, Gemini, Codex, Cursor, AMP, OpenCode, Copilot и других.
+- Scheduler docs по-прежнему подтверждают `scheduler.max_polecats`, deferred dispatch, capacity governor, pause/resume и daemon dispatch cycle.
+- Dashboard остаётся monitoring view for agents, convoys, hooks, queues, issues and escalations, а не Kanban product.
+- Refinery merge queue есть, но это не hunk-level diff review UI.
+
+Публичная оценка не меняется:
+
+- `Task dependencies` - `✅ Beads DAG waves`
+- `Kanban board` - `❌ Dashboard, not Kanban`
+- `Per-task code review` - `⚠️ Merge queue, no diff UI`
+- `Budget controls` - `⚠️ Cost tiers + digest, no hard caps`
+
+### Paperclip
+
+- README по-прежнему описывает org charts, budgets, governance, goal alignment and agent coordination.
+- Adapter overview подтверждает Claude Local, Codex Local, Gemini experimental, OpenCode Local, Cursor, OpenClaw Gateway, Process and HTTP adapters.
+- Budget docs подтверждают per-agent monthly budgets, warning threshold at 80%, hard stop at 100%, auto-pause and no more heartbeats.
+- Runtime services docs подтверждают manual UI-managed services/jobs and execution workspaces with isolated checkout/branch/runtime state.
+- Kanban source по-прежнему содержит `backlog`, `todo`, `in_progress`, `in_review`, `blocked`, `done`, `cancelled` and `@dnd-kit`.
+- Work product validators подтверждают `preview_url`, `runtime_service`, `pull_request`, `branch`, `commit`, `artifact`, `document` and review statuses.
+
+Публичная оценка не меняется:
+
+- `Kanban board` - `✅ 7 columns, drag-and-drop`
+- `Per-task code review` - `⚠️ PR/work products, no inline diff`
+- `Hunk-level review` - `❌ Bring your own review`
+- `Budget controls` - `✅ Per-agent budgets + hard stops`
+
+### Cursor
+
+- Background Agents are asynchronous remote agents that edit/run code in isolated machines, use GitHub branches, support follow-ups and can auto-run terminal commands.
+- Diffs & Review still supports diff review with accept/reject flows and selective acceptance.
+- Bugbot still focuses on PR review and comments with explanations/fix suggestions; pricing remains separate from normal Cursor subscriptions.
+- Usage/pricing docs still describe Free + paid usage, included agent usage by plan, dashboard token/usage breakdowns, background-agent access and spend limits.
+
+Публичная оценка не меняется:
+
+- `Full autonomy` - `⚠️ Background agents, not teams`
+- `Hunk-level review` - `✅`
+- `Review workflow` - `⚠️ PR/BugBot only`
+- `Flexible autonomy` - `⚠️ BG agents auto-run commands`
+- `Price` - `Free + paid usage`
+
+### Claude Code CLI
+
+- Agent teams are still experimental and disabled by default through `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS`.
+- Official docs still confirm shared task list, mailbox, direct teammate messaging, task dependencies, plan approval requests, quality-gate hooks and local team/task storage.
+- Agent teams require Claude Code `v2.1.32` or later according to the docs.
+- Worktrees remain an official workflow for isolated sessions, but this is not the same as a product-level worktree strategy UI.
+- Cost docs now use `/usage` for detailed token usage statistics, plus workspace spend limits and usage reporting in Console for API users.
+
+Публичная оценка после свежей проверки:
+
+- `Agent-to-agent messaging` - `✅ Team mailbox, no UI`
+- `Linked tasks` - `✅ Shared task list`
+- `Task dependencies` - `✅ Team task deps, no UI`
+- `Budget controls` - `⚠️ /usage + workspace limits`
+- `Multi-agent backend` - `⚠️ Claude-only experimental teams`
+
+## Источники
+
+- Gastown repo:
+- Gastown v1.1.0:
+- Gastown provider guide:
+- Gastown scheduler docs:
+- Paperclip repo:
+- Paperclip v2026.513.0:
+- Paperclip adapters:
+- Paperclip costs and budgets docs:
+- Paperclip runtime services docs:
+- Paperclip Kanban source:
+- Paperclip work products source:
+- Cursor Background Agents:
+- Cursor Diffs & Review:
+- Cursor Bugbot:
+- Cursor usage/pricing:
+- Claude Code agent teams:
+- Claude Code subagents:
+- Claude Code common workflows:
+- Claude Code costs:
+- Claude pricing:
diff --git a/docs/research/real-competitors-comparison.md b/docs/research/real-competitors-comparison.md
index 26121cae..e189bb39 100644
--- a/docs/research/real-competitors-comparison.md
+++ b/docs/research/real-competitors-comparison.md
@@ -1,6 +1,6 @@
# Реальные конкуренты для Comparison в README
-> ⚠️ Update 2026-05-05: публичная таблица README/landing теперь сравнивает нас с `Gastown` и `Paperclip`, а не с `Claude Code Agent Teams` и `GoClaw`. Актуальная research-опора: [gastown-paperclip-comparison-2026-05-05.md](gastown-paperclip-comparison-2026-05-05.md). Ниже оставлен старый broader draft как исторический контекст.
+> ⚠️ Update 2026-05-16: публичная таблица README/landing теперь сравнивает нас с `Gastown` и `Paperclip`, а не с `Claude Code Agent Teams` и `GoClaw`. Актуальная research-опора: [gastown-paperclip-comparison-2026-05-16.md](gastown-paperclip-comparison-2026-05-16.md). Ниже оставлен старый broader draft как исторический контекст.
> Дата проверки: 2026-04-13
> Статус: внутренний comparison draft
diff --git a/package.json b/package.json
index 4dcb49dc..b886f02b 100644
--- a/package.json
+++ b/package.json
@@ -131,6 +131,7 @@
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-presence": "^1.1.5",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
@@ -350,7 +351,10 @@
"electron",
"node-pty",
"cpu-features"
- ]
+ ],
+ "patchedDependencies": {
+ "@radix-ui/react-presence@1.1.5": "patches/@radix-ui__react-presence@1.1.5.patch"
+ }
},
"knip": {
"entry": [
diff --git a/patches/@radix-ui__react-presence@1.1.5.patch b/patches/@radix-ui__react-presence@1.1.5.patch
new file mode 100644
index 00000000..8dc0bcac
--- /dev/null
+++ b/patches/@radix-ui__react-presence@1.1.5.patch
@@ -0,0 +1,86 @@
+diff --git a/dist/index.js b/dist/index.js
+index 944abc2652716a5047360c8abc2d48e700a3f3f8..e0d98a35aead8e057a304734627e0022643cb71c 100644
+--- a/dist/index.js
++++ b/dist/index.js
+@@ -62,6 +62,8 @@ var Presence = (props) => {
+ Presence.displayName = "Presence";
+ function usePresence(present) {
+ const [node, setNode] = React2.useState();
++ const nodeRef = React2.useRef();
++ const nodeCleanupGenerationRef = React2.useRef(0);
+ const stylesRef = React2.useRef(null);
+ const prevPresentRef = React2.useRef(present);
+ const prevAnimationNameRef = React2.useRef("none");
+@@ -146,8 +148,27 @@ function usePresence(present) {
+ return {
+ isPresent: ["mounted", "unmountSuspended"].includes(state),
+ ref: React2.useCallback((node2) => {
+- stylesRef.current = node2 ? getComputedStyle(node2) : null;
+- setNode(node2);
++ const syncNode = (nextNode) => {
++ if (nodeRef.current === nextNode) {
++ stylesRef.current = nextNode ? getComputedStyle(nextNode) : null;
++ return;
++ }
++ nodeRef.current = nextNode;
++ stylesRef.current = nextNode ? getComputedStyle(nextNode) : null;
++ setNode(nextNode);
++ };
++ nodeCleanupGenerationRef.current += 1;
++ const cleanupGeneration = nodeCleanupGenerationRef.current;
++ if (node2) {
++ syncNode(node2);
++ return;
++ }
++ queueMicrotask(() => {
++ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
++ return;
++ }
++ syncNode(null);
++ });
+ }, [])
+ };
+ }
+diff --git a/dist/index.mjs b/dist/index.mjs
+index 0efe02a3c45790c11f94f71563bf1db7257b799f..67db6319fb2af1008e2da62ec8cdb21e4903ea1e 100644
+--- a/dist/index.mjs
++++ b/dist/index.mjs
+@@ -26,6 +26,8 @@ var Presence = (props) => {
+ Presence.displayName = "Presence";
+ function usePresence(present) {
+ const [node, setNode] = React2.useState();
++ const nodeRef = React2.useRef();
++ const nodeCleanupGenerationRef = React2.useRef(0);
+ const stylesRef = React2.useRef(null);
+ const prevPresentRef = React2.useRef(present);
+ const prevAnimationNameRef = React2.useRef("none");
+@@ -110,8 +112,27 @@ function usePresence(present) {
+ return {
+ isPresent: ["mounted", "unmountSuspended"].includes(state),
+ ref: React2.useCallback((node2) => {
+- stylesRef.current = node2 ? getComputedStyle(node2) : null;
+- setNode(node2);
++ const syncNode = (nextNode) => {
++ if (nodeRef.current === nextNode) {
++ stylesRef.current = nextNode ? getComputedStyle(nextNode) : null;
++ return;
++ }
++ nodeRef.current = nextNode;
++ stylesRef.current = nextNode ? getComputedStyle(nextNode) : null;
++ setNode(nextNode);
++ };
++ nodeCleanupGenerationRef.current += 1;
++ const cleanupGeneration = nodeCleanupGenerationRef.current;
++ if (node2) {
++ syncNode(node2);
++ return;
++ }
++ queueMicrotask(() => {
++ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
++ return;
++ }
++ syncNode(null);
++ });
+ }, [])
+ };
+ }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d8b7e807..be2c7ae6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,11 @@ overrides:
lodash-es: ^4.18.1
uuid: ^11.1.1
+patchedDependencies:
+ '@radix-ui/react-presence@1.1.5':
+ hash: afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e
+ path: patches/@radix-ui__react-presence@1.1.5.patch
+
importers:
.:
@@ -138,6 +143,9 @@ importers:
'@radix-ui/react-popover':
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-presence':
+ specifier: ^1.1.5
+ version: 1.1.5(patch_hash=afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e)(@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-select':
specifier: ^2.2.6
version: 2.2.6(@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)
@@ -13909,7 +13917,7 @@ snapshots:
'@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-presence': 1.1.5(@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-presence': 1.1.5(patch_hash=afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e)(@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)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
@@ -13926,7 +13934,7 @@ snapshots:
'@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-presence': 1.1.5(@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-presence': 1.1.5(patch_hash=afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e)(@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)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
@@ -13984,7 +13992,7 @@ snapshots:
'@radix-ui/react-focus-scope': 1.1.7(@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-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-portal': 1.1.9(@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-presence': 1.1.5(@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-presence': 1.1.5(patch_hash=afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e)(@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-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
@@ -14055,7 +14063,7 @@ snapshots:
'@radix-ui/react-dismissable-layer': 1.1.11(@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-popper': 1.2.8(@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-portal': 1.1.9(@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-presence': 1.1.5(@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-presence': 1.1.5(patch_hash=afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e)(@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
@@ -14093,7 +14101,7 @@ snapshots:
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-popper': 1.2.8(@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-portal': 1.1.9(@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-presence': 1.1.5(@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-presence': 1.1.5(patch_hash=afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e)(@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-roving-focus': 1.1.11(@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-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
@@ -14117,7 +14125,7 @@ snapshots:
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-popper': 1.2.8(@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-portal': 1.1.9(@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-presence': 1.1.5(@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-presence': 1.1.5(patch_hash=afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e)(@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-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
@@ -14157,7 +14165,7 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-presence@1.1.5(@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-presence@1.1.5(patch_hash=afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e)(@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/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
@@ -14251,7 +14259,7 @@ snapshots:
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-direction': 1.1.1(@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-presence': 1.1.5(@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-presence': 1.1.5(patch_hash=afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e)(@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-roving-focus': 1.1.11(@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)
@@ -14270,7 +14278,7 @@ snapshots:
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-popper': 1.2.8(@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-portal': 1.1.9(@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-presence': 1.1.5(@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-presence': 1.1.5(patch_hash=afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e)(@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-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx
index d1f60117..e27ca731 100644
--- a/src/renderer/components/dashboard/CliStatusBanner.tsx
+++ b/src/renderer/components/dashboard/CliStatusBanner.tsx
@@ -123,7 +123,10 @@ const DashboardRateLimitChips = ({
>
{item.label}
-
+
{item.remaining}
{
label: '5h left',
remaining: '75%',
resetsAt: 'reset unknown',
+ isDepleted: false,
},
{
label: 'Weekly left',
remaining: '50%',
resetsAt: 'reset unknown',
+ isDepleted: false,
},
]);
});
@@ -188,10 +190,42 @@ describe('providerDashboardRateLimits', () => {
label: '5h left',
remaining: '80%',
resetsAt: 'reset unknown',
+ isDepleted: false,
},
]);
});
+ test('marks fully depleted limits when no quota remains', () => {
+ const connection = createCodexConnection();
+
+ const items = getCodexDashboardRateLimits(
+ createProvider({
+ providerId: 'codex',
+ displayName: 'Codex',
+ authMethod: 'oauth_token',
+ connection: {
+ ...connection,
+ codex: {
+ ...connection.codex!,
+ rateLimits: {
+ ...connection.codex!.rateLimits!,
+ primary: {
+ usedPercent: 100,
+ windowDurationMins: 300,
+ resetsAt: null,
+ },
+ },
+ },
+ },
+ })
+ );
+
+ expect(items?.[0]).toMatchObject({
+ remaining: '0%',
+ isDepleted: true,
+ });
+ });
+
test('shows Anthropic rate limit skeletons when subscription mode is selected in config', () => {
expect(
shouldShowDashboardRateLimitSkeleton({
diff --git a/src/renderer/components/dashboard/providerDashboardRateLimits.ts b/src/renderer/components/dashboard/providerDashboardRateLimits.ts
index 3a194381..969a72ed 100644
--- a/src/renderer/components/dashboard/providerDashboardRateLimits.ts
+++ b/src/renderer/components/dashboard/providerDashboardRateLimits.ts
@@ -14,6 +14,7 @@ export interface DashboardRateLimitItem {
label: string;
remaining: string;
resetsAt: string;
+ isDepleted: boolean;
}
export interface DashboardRateLimitSkeletonModeInput {
@@ -180,6 +181,10 @@ function formatDashboardResetTime(timestampSeconds: number | null | undefined):
});
}
+function isRateLimitDepleted(usedPercent: number | null | undefined): boolean {
+ return typeof usedPercent === 'number' && Number.isFinite(usedPercent) && usedPercent >= 100;
+}
+
function buildRateLimitItem(
label: string,
usedPercent: number,
@@ -189,6 +194,7 @@ function buildRateLimitItem(
label,
remaining: formatCodexRemainingPercent(usedPercent) ?? 'Unknown',
resetsAt: formatDashboardResetTime(resetsAt),
+ isDepleted: isRateLimitDepleted(usedPercent),
};
}
diff --git a/src/renderer/components/team/dialogs/projectPathOptions.ts b/src/renderer/components/team/dialogs/projectPathOptions.ts
index 6cdae4b0..496ca3e9 100644
--- a/src/renderer/components/team/dialogs/projectPathOptions.ts
+++ b/src/renderer/components/team/dialogs/projectPathOptions.ts
@@ -13,7 +13,7 @@ export interface ProjectPathProject extends Project {
filesystemState?: DashboardRecentProjectFilesystemState;
}
-export interface ProjectPathOptionMeta {
+export interface ProjectPathOptionMeta extends Record {
discoverySource?: DashboardRecentProjectSource;
filesystemState?: DashboardRecentProjectFilesystemState;
}
diff --git a/src/renderer/components/ui/presence.react19.test.tsx b/src/renderer/components/ui/presence.react19.test.tsx
new file mode 100644
index 00000000..a44a68a2
--- /dev/null
+++ b/src/renderer/components/ui/presence.react19.test.tsx
@@ -0,0 +1,72 @@
+import React, { act, useState } from 'react';
+import { createRoot } from 'react-dom/client';
+
+import { Presence } from '@radix-ui/react-presence';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const flushMicrotasks = async (): Promise => {
+ await Promise.resolve();
+ await Promise.resolve();
+};
+
+describe('Radix Presence React 19 compatibility', () => {
+ beforeEach(() => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ vi.unstubAllGlobals();
+ });
+
+ it('does not recurse when a composed child ref changes identity and returns cleanup', async () => {
+ const refEvents: string[] = [];
+
+ const Harness = (): React.JSX.Element => {
+ const [tick, setTick] = useState(0);
+
+ return (
+
+
setTick((value) => value + 1)}>
+ rerender
+
+
+ {
+ 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();
+ });
+ });
+});