diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 6bcd9cab..a4a7ae60 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -25,6 +25,7 @@ import { type SessionMetadataLevel, type SessionsByIdsOptions, type SessionsPaginationOptions, + type WorktreeSource, } from '@main/types'; import { analyzeSessionFileMetadata, @@ -45,6 +46,19 @@ import { import { createLogger } from '@shared/utils/logger'; import * as path from 'path'; +import { + AUTO_CLAUDE_DIR, + CCSWITCH_DIR, + CLAUDE_CODE_DIR, + CLAUDE_WORKTREES_DIR, + CONDUCTOR_DIR, + CURSOR_DIR, + TWENTYFIRST_DIR, + VIBE_KANBAN_DIR, + WORKSPACES_DIR, + WORKTREES_DIR, +} from '@main/constants/worktreePatterns'; + import { LocalFileSystemProvider } from '../infrastructure/LocalFileSystemProvider'; import { ProjectPathResolver } from './ProjectPathResolver'; @@ -52,7 +66,6 @@ import { SessionContentFilter } from './SessionContentFilter'; import { SessionSearcher } from './SessionSearcher'; import { SubagentLocator } from './SubagentLocator'; import { subprojectRegistry } from './SubprojectRegistry'; -import { WorktreeGrouper } from './WorktreeGrouper'; import type { FileSystemProvider, FsDirent } from '../infrastructure/FileSystemProvider'; @@ -64,6 +77,51 @@ const logger = createLogger('Discovery:ProjectScanner'); // for lookups and navigation; a small cap preserves that behavior without huge payloads. const MAX_SESSION_IDS_EXPORTED = 200; +/** + * Fast, zero-I/O worktree detection based on path patterns only. + * Used by scanWithWorktreeGrouping to provide accurate worktree metadata + * without expensive git filesystem operations. + */ +function detectWorktreeFromPath(projectPath: string): { + isWorktree: boolean; + source: WorktreeSource; +} { + const parts = projectPath.split(path.sep).filter(Boolean); + + if (parts.includes(VIBE_KANBAN_DIR) && parts.includes(WORKTREES_DIR)) { + return { isWorktree: true, source: 'vibe-kanban' }; + } + if (parts.includes(CONDUCTOR_DIR) && parts.includes(WORKSPACES_DIR)) { + // Only subpaths after workspaces/{repo} are worktrees + const idx = parts.indexOf(CONDUCTOR_DIR); + if (idx >= 0 && parts.length > idx + 3) { + return { isWorktree: true, source: 'conductor' }; + } + } + if (parts.includes(AUTO_CLAUDE_DIR) && parts.includes(WORKTREES_DIR)) { + return { isWorktree: true, source: 'auto-claude' }; + } + if (parts.includes(TWENTYFIRST_DIR) && parts.includes(WORKTREES_DIR)) { + return { isWorktree: true, source: '21st' }; + } + if (parts.includes(CLAUDE_WORKTREES_DIR)) { + return { isWorktree: true, source: 'claude-desktop' }; + } + if (parts.includes(CCSWITCH_DIR) && parts.includes(WORKTREES_DIR)) { + return { isWorktree: true, source: 'ccswitch' }; + } + if (parts.includes(CURSOR_DIR) && parts.includes(WORKTREES_DIR)) { + return { isWorktree: true, source: 'git' }; + } + { + const claudeCodeIdx = parts.indexOf(CLAUDE_CODE_DIR); + if (claudeCodeIdx >= 0 && parts[claudeCodeIdx + 1] === WORKTREES_DIR) { + return { isWorktree: true, source: 'claude-code' }; + } + } + return { isWorktree: false, source: 'unknown' }; +} + export class ProjectScanner { private readonly projectsDir: string; private readonly todosDir: string; @@ -96,7 +154,6 @@ export class ProjectScanner { // Delegated services private readonly fsProvider: FileSystemProvider; private readonly sessionContentFilter: typeof SessionContentFilter; - private readonly worktreeGrouper: WorktreeGrouper; private readonly subagentLocator: SubagentLocator; private readonly sessionSearcher: SessionSearcher; private readonly projectPathResolver: ProjectPathResolver; @@ -108,7 +165,6 @@ export class ProjectScanner { // Initialize delegated services this.sessionContentFilter = SessionContentFilter; - this.worktreeGrouper = new WorktreeGrouper(this.projectsDir, this.fsProvider); this.subagentLocator = new SubagentLocator(this.projectsDir, this.fsProvider); this.sessionSearcher = new SessionSearcher(this.projectsDir, this.fsProvider); this.projectPathResolver = new ProjectPathResolver(this.projectsDir, this.fsProvider); @@ -230,6 +286,7 @@ export class ProjectScanner { // Each project becomes a single-worktree group. const groups: RepositoryGroup[] = projects.map((project) => { const totalSessions = project.totalSessions ?? project.sessions.length; + const worktreeInfo = detectWorktreeFromPath(project.path); return { id: project.id, identity: null, @@ -238,8 +295,8 @@ export class ProjectScanner { id: project.id, path: project.path, name: project.name, - isMainWorktree: true, - source: 'unknown' as const, + isMainWorktree: !worktreeInfo.isWorktree, + source: worktreeInfo.source, sessions: project.sessions, totalSessions, createdAt: project.createdAt, diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 140828ee..b8723175 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -19,6 +19,7 @@ import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess'; import { appendCliAuthDiag } from '@main/utils/cliAuthDiagLog'; +import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { buildMergedCliPath } from '@main/utils/cliPathMerge'; import { getClaudeBasePath, getHomeDir } from '@main/utils/pathDecoder'; import { @@ -32,7 +33,7 @@ import { createHash } from 'crypto'; import { createWriteStream, existsSync, promises as fsp } from 'fs'; import http from 'http'; import https from 'https'; -import { tmpdir, userInfo } from 'os'; +import { tmpdir } from 'os'; import { join, posix as pathPosix, win32 as pathWin32 } from 'path'; import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver'; @@ -82,29 +83,6 @@ const AUTH_STATUS_MAX_RETRIES = 2; /** Delay before retrying auth status check (ms) — gives previous process time to clean up */ const AUTH_STATUS_RETRY_DELAY_MS = 1500; -/** - * Build env for child processes with correct HOME and enriched PATH. - * PATH merging lives in `cliPathMerge.ts` (shared with binary discovery). - */ -function buildChildEnv(binaryPath?: string | null): NodeJS.ProcessEnv { - const home = getShellPreferredHome(); - const shellEnv = getCachedShellEnv(); - let osUsername = ''; - try { - osUsername = userInfo().username; - } catch { - // userInfo() can throw in restricted environments (Docker, no passwd entry) - } - const user = shellEnv?.USER?.trim() || process.env.USER?.trim() || osUsername || ''; - return { - ...process.env, - HOME: home, - USERPROFILE: home, - PATH: buildMergedCliPath(binaryPath), - ...(user ? { USER: user, LOGNAME: user } : {}), - }; -} - /** `claude auth status` may prefix stderr noise or warnings; extract the JSON object. */ function parseClaudeAuthStatusStdout(stdout: string): { loggedIn?: boolean; authMethod?: string } { const trimmed = stdout.trim(); @@ -388,12 +366,7 @@ export class CliInstallerService { * Env for CLI subprocesses: login-shell vars + consistent HOME/PATH + same config root as the app. */ private envForCli(binaryPath: string): NodeJS.ProcessEnv { - return { - ...process.env, - ...(getCachedShellEnv() ?? {}), - ...buildChildEnv(binaryPath), - CLAUDE_CONFIG_DIR: getClaudeBasePath(), - }; + return buildEnrichedEnv(binaryPath); } // --------------------------------------------------------------------------- diff --git a/src/main/services/schedule/ScheduledTaskExecutor.ts b/src/main/services/schedule/ScheduledTaskExecutor.ts index 4bd19749..a16cb57a 100644 --- a/src/main/services/schedule/ScheduledTaskExecutor.ts +++ b/src/main/services/schedule/ScheduledTaskExecutor.ts @@ -9,6 +9,7 @@ */ import { killProcessTree, spawnCli } from '@main/utils/childProcess'; +import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { createLogger } from '@shared/utils/logger'; @@ -102,7 +103,9 @@ export class ScheduledTaskExecutor { const child = spawnCli(binaryPath, args, { cwd: request.config.cwd, - env: { ...process.env, ...shellEnv, CLAUDECODE: undefined }, + // shellEnv spread after buildEnrichedEnv ensures freshly-resolved values + // take precedence over the cached snapshot inside buildEnrichedEnv. + env: { ...buildEnrichedEnv(binaryPath), ...shellEnv, CLAUDECODE: undefined }, stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/src/main/utils/cliEnv.ts b/src/main/utils/cliEnv.ts index bf08528a..c6ac68f6 100644 --- a/src/main/utils/cliEnv.ts +++ b/src/main/utils/cliEnv.ts @@ -1,22 +1,47 @@ /** * Builds an enriched environment for Claude CLI child processes. * - * Packaged Electron apps on macOS receive a minimal PATH (often just /usr/bin:/bin). + * Packaged Electron apps on macOS receive a minimal PATH (often just /usr/bin:/bin) + * and may lack USER (needed for macOS Keychain credential lookup). * This helper merges the user's interactive-shell env (cached during startup) with * common install locations so that `claude` and its subprocesses (node, npx, etc.) - * can find the tools they need. + * can find the tools they need and authenticate properly. */ +import { userInfo } from 'os'; + import { buildMergedCliPath } from '@main/utils/cliPathMerge'; +import { getClaudeBasePath } from '@main/utils/pathDecoder'; import { getCachedShellEnv, getShellPreferredHome } from '@main/utils/shellEnv'; export function buildEnrichedEnv(binaryPath?: string | null): NodeJS.ProcessEnv { + const shellEnv = getCachedShellEnv(); const home = getShellPreferredHome(); + let osUsername = ''; + try { + osUsername = userInfo().username; + } catch { + // userInfo() can throw in restricted environments (Docker, no passwd entry) + } + const user = + shellEnv?.USER?.trim() || + process.env.USER?.trim() || + process.env.USERNAME?.trim() || + osUsername || + ''; + return { ...process.env, - ...(getCachedShellEnv() ?? {}), + ...(shellEnv ?? {}), HOME: home, USERPROFILE: home, PATH: buildMergedCliPath(binaryPath), + CLAUDE_CONFIG_DIR: getClaudeBasePath(), + ...(user + ? { + USER: user, + LOGNAME: shellEnv?.LOGNAME?.trim() || process.env.LOGNAME?.trim() || user, + } + : {}), }; } diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 8ae46996..e6605826 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1819,6 +1819,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele open={addMemberDialogOpen} teamName={teamName} existingNames={data.members.map((m) => m.name)} + existingMembers={data.members} projectPath={data.config.projectPath} adding={addingMemberLoading} onClose={() => setAddMemberDialogOpen(false)} diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index fae8169f..4f10d048 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -405,7 +405,13 @@ export const TeamListView = (): React.JSX.Element => { if (projA !== projB) return projA - projB; } - return 0; + // 3. Most recently active teams first (stable secondary sort) + const tsA = a.lastActivity ? new Date(a.lastActivity).getTime() : 0; + const tsB = b.lastActivity ? new Date(b.lastActivity).getTime() : 0; + if (tsA !== tsB) return tsB - tsA; + + // 4. Fallback: alphabetical by team name for deterministic order + return a.teamName.localeCompare(b.teamName); }); return result; @@ -808,17 +814,7 @@ export const TeamListView = (): React.JSX.Element => { } }} > - {teamColorSet ? ( -
- ) : null} -
+

diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 3000393e..667358c9 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -27,6 +27,7 @@ import { } from '@renderer/utils/messageRenderEquality'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; +import { isThoughtProtocolNoise } from '@shared/utils/inboxNoise'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; import { ChevronDown, ChevronRight, ChevronUp, Maximize2 } from 'lucide-react'; @@ -60,6 +61,8 @@ export function isLeadThought(msg: InboxMessage): boolean { if (typeof msg.to === 'string' && msg.to.trim().length > 0) return false; // Compaction boundary events are system messages, not lead thoughts if (isCompactionMessage(msg)) return false; + // Protocol noise (JSON coordination signals, raw teammate-message XML) should be hidden + if (isThoughtProtocolNoise(msg.text)) return false; if (msg.source === 'lead_session') return true; if (msg.source === 'lead_process') return true; return false; diff --git a/src/renderer/components/team/activity/ThoughtBodyContent.tsx b/src/renderer/components/team/activity/ThoughtBodyContent.tsx index 78fa4255..d4b7c212 100644 --- a/src/renderer/components/team/activity/ThoughtBodyContent.tsx +++ b/src/renderer/components/team/activity/ThoughtBodyContent.tsx @@ -12,6 +12,7 @@ import { } from '@renderer/utils/messageRenderEquality'; import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; +import { stripTeammateMessageBlocks } from '@shared/utils/inboxNoise'; import { Reply } from 'lucide-react'; import { formatTimeWithSec, ToolSummaryTooltipContent } from './LeadThoughtsGroup'; @@ -41,7 +42,8 @@ export const ThoughtBodyContent = memo( onTeamClick, }: ThoughtBodyContentProps): JSX.Element { const displayContent = useMemo(() => { - let text = thought.text.replace(/\n/g, ' \n'); + // Strip leaked protocol XML ( blocks) before rendering + let text = stripTeammateMessageBlocks(thought.text).replace(/\n/g, ' \n'); text = linkifyTaskIdsInMarkdown(text, thought.taskRefs); if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) { text = linkifyAllMentionsInMarkdown( diff --git a/src/renderer/components/team/attachments/DropZoneOverlay.tsx b/src/renderer/components/team/attachments/DropZoneOverlay.tsx index 1f7bce80..b9c4ad8e 100644 --- a/src/renderer/components/team/attachments/DropZoneOverlay.tsx +++ b/src/renderer/components/team/attachments/DropZoneOverlay.tsx @@ -4,11 +4,14 @@ interface DropZoneOverlayProps { active: boolean; /** Show a "rejected" variant when files can't be sent to this recipient. */ rejected?: boolean; + /** Custom rejection message. Defaults to generic restriction text. */ + rejectionReason?: string; } export const DropZoneOverlay = ({ active, rejected, + rejectionReason, }: DropZoneOverlayProps): React.JSX.Element | null => { if (!active) return null; @@ -23,7 +26,9 @@ export const DropZoneOverlay = ({ >
- Files can only be sent to the team lead + + {rejectionReason ?? 'Files can only be sent to the team lead'} +

); diff --git a/src/renderer/components/team/dialogs/AddMemberDialog.tsx b/src/renderer/components/team/dialogs/AddMemberDialog.tsx index f18e32de..3fab05b1 100644 --- a/src/renderer/components/team/dialogs/AddMemberDialog.tsx +++ b/src/renderer/components/team/dialogs/AddMemberDialog.tsx @@ -36,6 +36,8 @@ interface AddMemberDialogProps { adding?: boolean; /** Project path for @file mentions in workflow field. */ projectPath?: string | null; + /** Existing team members with their colors — used so new drafts get the next available color */ + existingMembers?: readonly { name: string; color?: string; removedAt?: number | string | null }[]; } const DIALOG_WIDTH = 'w-[720px]'; @@ -53,6 +55,7 @@ export const AddMemberDialog = ({ onAdd, adding, projectPath, + existingMembers, }: AddMemberDialogProps): React.JSX.Element => { const [members, setMembers] = useState(() => buildInitialDrafts(existingNames)); const [error, setError] = useState(null); @@ -158,6 +161,7 @@ export const AddMemberDialog = ({ showJsonEditor={false} draftKeyPrefix={`addMember:${teamName}`} projectPath={projectPath} + existingMembers={existingMembers} />
diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index b5b4d7d6..cf36a360 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -136,6 +136,11 @@ export const SendMessageDialog = ({ const shouldAutoDelegate = canDelegate; const supportsAttachments = isLeadRecipient && !!isTeamAlive; const canAttach = supportsAttachments && canAddMore; + const attachmentRestrictionReason = !supportsAttachments + ? !isLeadRecipient + ? 'Files can only be sent to the team lead' + : 'Team must be online to attach files' + : undefined; // Auto-switch to delegate when lead recipient is selected, but don't // override user's explicit choice on dialog open. @@ -281,12 +286,14 @@ export const SendMessageDialog = ({ ); const showFileRestrictionError = useCallback(() => { - setFileRestrictionError('Files can only be sent to the team lead'); + setFileRestrictionError( + attachmentRestrictionReason ?? 'Files can only be sent to the team lead' + ); window.clearTimeout(fileRestrictionTimerRef.current); fileRestrictionTimerRef.current = window.setTimeout(() => { setFileRestrictionError(null); }, 4000); - }, []); + }, [attachmentRestrictionReason]); // Cleanup restriction error timer on unmount useEffect(() => { @@ -355,7 +362,11 @@ export const SendMessageDialog = ({ onDrop={handleDropWrapper} onPaste={handlePasteWrapper} > - + Send Message diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index b8415b5b..ace16587 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -1315,10 +1315,12 @@ const CommentImagesGrid = ({ const [previewUrl, setPreviewUrl] = useState(null); return ( -
-
- - From comments +
+
+ + + From comments +
{items.map((item) => ( diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index 1728a6e4..68cd000f 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -37,28 +37,28 @@ const COLUMN_ACCENTS: Record< { headerBg: string; bodyBg: string; icon: React.ReactNode } > = { todo: { - headerBg: 'rgba(59, 130, 246, 0.12)', - bodyBg: 'rgba(59, 130, 246, 0.05)', + headerBg: 'rgba(59, 130, 246, 0.15)', + bodyBg: 'rgba(59, 130, 246, 0.015)', icon: , }, in_progress: { - headerBg: 'rgba(234, 179, 8, 0.14)', - bodyBg: 'rgba(234, 179, 8, 0.06)', + headerBg: 'rgba(234, 179, 8, 0.18)', + bodyBg: 'rgba(234, 179, 8, 0.018)', icon: , }, done: { - headerBg: 'rgba(34, 197, 94, 0.12)', - bodyBg: 'rgba(34, 197, 94, 0.05)', + headerBg: 'rgba(34, 197, 94, 0.15)', + bodyBg: 'rgba(34, 197, 94, 0.015)', icon: , }, review: { - headerBg: 'rgba(139, 92, 246, 0.12)', - bodyBg: 'rgba(139, 92, 246, 0.05)', + headerBg: 'rgba(139, 92, 246, 0.15)', + bodyBg: 'rgba(139, 92, 246, 0.015)', icon: , }, approved: { - headerBg: 'rgba(34, 197, 94, 0.24)', - bodyBg: 'rgba(34, 197, 94, 0.11)', + headerBg: 'rgba(34, 197, 94, 0.28)', + bodyBg: 'rgba(34, 197, 94, 0.033)', icon: , }, }; diff --git a/src/renderer/components/team/kanban/KanbanColumn.tsx b/src/renderer/components/team/kanban/KanbanColumn.tsx index e883e281..68db2d03 100644 --- a/src/renderer/components/team/kanban/KanbanColumn.tsx +++ b/src/renderer/components/team/kanban/KanbanColumn.tsx @@ -47,7 +47,7 @@ export const KanbanColumn = ({ )}
{ const [jsonEditorOpen, setJsonEditorOpen] = useState(false); const [jsonText, setJsonText] = useState(''); @@ -158,7 +161,10 @@ export const MembersEditorSection = ({ const names = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean); const hasDuplicates = new Set(names).size !== names.length; - const memberColorMap = useMemo(() => buildMemberDraftColorMap(members), [members]); + const memberColorMap = useMemo( + () => buildMemberDraftColorMap(members, existingMembers), + [members, existingMembers] + ); const mentionSuggestions = useMemo( () => buildMemberDraftSuggestions(members, memberColorMap), diff --git a/src/renderer/components/team/members/membersEditorUtils.ts b/src/renderer/components/team/members/membersEditorUtils.ts index 55031085..8bafddc2 100644 --- a/src/renderer/components/team/members/membersEditorUtils.ts +++ b/src/renderer/components/team/members/membersEditorUtils.ts @@ -36,15 +36,37 @@ export function createMemberDraft(initial?: Partial): MemberDraft { }; } +interface ExistingMemberColorInput { + name: string; + color?: string; + removedAt?: number | string | null; +} + export function buildMemberDraftColorMap( - members: readonly Pick[] + members: readonly Pick[], + existingMembers?: readonly ExistingMemberColorInput[] ): Map { - return buildMemberColorMap( - members - .map((member) => member.name.trim()) - .filter(Boolean) - .map((name) => ({ name })) - ); + const draftEntries = members + .map((member) => member.name.trim()) + .filter(Boolean) + .map((name) => ({ name })); + + // When existing members are provided, include them first so their colors + // are reserved and new drafts receive the next available palette entries. + const allEntries = existingMembers ? [...existingMembers, ...draftEntries] : draftEntries; + + const fullMap = buildMemberColorMap(allEntries); + + // Return only draft entries so callers don't see existing-member keys + // they didn't ask for (keeps the API surface unchanged). + if (!existingMembers) return fullMap; + + const draftMap = new Map(); + for (const entry of draftEntries) { + const color = fullMap.get(entry.name); + if (color) draftMap.set(entry.name, color); + } + return draftMap; } /** Resolves a MemberDraft's role selection to a display string. */ diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 1d3a610a..30902e9d 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -257,6 +257,13 @@ export const MessageComposer = ({ // ); const supportsAttachments = isLeadRecipient && !isCrossTeam && !!isTeamAlive; const canAttach = supportsAttachments && draft.canAddMore; + const attachmentRestrictionReason = !supportsAttachments + ? isCrossTeam + ? 'File attachments are not supported for cross-team messages' + : !isLeadRecipient + ? 'Files can only be sent to the team lead' + : 'Team must be online to attach files' + : undefined; const attachmentsBlocked = draft.attachments.length > 0 && !supportsAttachments; const canSend = recipient.length > 0 && @@ -332,12 +339,14 @@ export const MessageComposer = ({ ); const showFileRestrictionError = useCallback(() => { - setFileRestrictionError('Files can only be sent to the team lead'); + setFileRestrictionError( + attachmentRestrictionReason ?? 'Files can only be sent to the team lead' + ); window.clearTimeout(fileRestrictionTimerRef.current); fileRestrictionTimerRef.current = window.setTimeout(() => { setFileRestrictionError(null); }, 4000); - }, []); + }, [attachmentRestrictionReason]); // Cleanup restriction error timer on unmount useEffect(() => { @@ -841,7 +850,11 @@ export const MessageComposer = ({
- + ]*>[\s\S]*?<\/teammate-message>/g; + +/** + * Removes `` XML blocks from text. + * Used to clean protocol artifacts that leak into lead thoughts. + */ +export function stripTeammateMessageBlocks(text: string): string { + return text + .replace(TEAMMATE_MESSAGE_BLOCK_RE, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +/** + * Returns true if the entire text consists only of `` blocks + * (possibly with whitespace between them) and no meaningful user-visible content. + */ +export function isOnlyTeammateMessageBlocks(text: string): boolean { + const stripped = stripTeammateMessageBlocks(text); + return stripped.length === 0; +} + +// --------------------------------------------------------------------------- +// Combined protocol noise check for lead thoughts +// --------------------------------------------------------------------------- + +/** + * Returns true if a lead thought text is entirely protocol noise and should + * be hidden from the user. Covers: + * 1. Structured JSON noise (idle_notification, shutdown_*, etc.) + * 2. Text that consists solely of `` XML blocks + */ +export function isThoughtProtocolNoise(text: string): boolean { + if (isInboxNoiseMessage(text)) return true; + if (isOnlyTeammateMessageBlocks(text)) return true; + return false; +} diff --git a/test/main/services/schedule/ScheduledTaskExecutor.test.ts b/test/main/services/schedule/ScheduledTaskExecutor.test.ts index 6c682b6a..2b4fe5f7 100644 --- a/test/main/services/schedule/ScheduledTaskExecutor.test.ts +++ b/test/main/services/schedule/ScheduledTaskExecutor.test.ts @@ -26,6 +26,10 @@ vi.mock('@main/utils/shellEnv', () => ({ resolveInteractiveShellEnv: () => mockResolveShellEnv(), })); +vi.mock('@main/utils/cliEnv', () => ({ + buildEnrichedEnv: () => ({ ...process.env }), +})); + vi.mock('../../../../src/main/services/team/ClaudeBinaryResolver', () => ({ ClaudeBinaryResolver: { resolve: () => mockResolve(), diff --git a/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts b/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts index 4663d981..526938cd 100644 --- a/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts +++ b/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts @@ -15,4 +15,69 @@ describe('LeadThoughtsGroup', () => { }) ).toBe(false); }); + + it('filters out idle_notification JSON noise from lead thoughts', () => { + expect( + isLeadThought({ + from: 'team-lead', + text: '{"type":"idle_notification","message":"alice is idle"}', + timestamp: '2026-03-08T00:00:00.000Z', + read: true, + source: 'lead_session', + }) + ).toBe(false); + }); + + it('filters out shutdown_request JSON noise from lead thoughts', () => { + expect( + isLeadThought({ + from: 'team-lead', + text: '{"type":"shutdown_request","reason":"Task complete"}', + timestamp: '2026-03-08T00:00:00.000Z', + read: true, + source: 'lead_process', + }) + ).toBe(false); + }); + + it('filters out pure XML blocks from lead thoughts', () => { + expect( + isLeadThought({ + from: 'team-lead', + text: 'Task completed', + timestamp: '2026-03-08T00:00:00.000Z', + read: true, + source: 'lead_session', + }) + ).toBe(false); + }); + + it('filters out multiple blocks with whitespace', () => { + const text = [ + 'Hello', + '', + 'OK', + ].join('\n'); + expect( + isLeadThought({ + from: 'team-lead', + text, + timestamp: '2026-03-08T00:00:00.000Z', + read: true, + source: 'lead_process', + }) + ).toBe(false); + }); + + it('keeps normal lead thoughts with real content', () => { + expect( + isLeadThought({ + from: 'team-lead', + text: 'Reviewing the implementation plan for the new feature.', + timestamp: '2026-03-08T00:00:00.000Z', + read: true, + source: 'lead_session', + }) + ).toBe(true); + }); }); diff --git a/test/shared/utils/inboxNoise.test.ts b/test/shared/utils/inboxNoise.test.ts new file mode 100644 index 00000000..cda94fff --- /dev/null +++ b/test/shared/utils/inboxNoise.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest'; + +import { + isInboxNoiseMessage, + isOnlyTeammateMessageBlocks, + isThoughtProtocolNoise, + stripTeammateMessageBlocks, +} from '../../../src/shared/utils/inboxNoise'; + +describe('stripTeammateMessageBlocks', () => { + it('removes a single teammate-message block', () => { + const text = + 'Hello world'; + expect(stripTeammateMessageBlocks(text)).toBe(''); + }); + + it('removes multiple teammate-message blocks', () => { + const text = [ + 'Hello', + 'OK', + ].join('\n'); + expect(stripTeammateMessageBlocks(text)).toBe(''); + }); + + it('preserves normal text around teammate-message blocks', () => { + const text = + 'Before\nHello\nAfter'; + expect(stripTeammateMessageBlocks(text)).toBe('Before\n\nAfter'); + }); + + it('returns text unchanged when no blocks are present', () => { + const text = 'Just some normal text without protocol blocks.'; + expect(stripTeammateMessageBlocks(text)).toBe(text); + }); +}); + +describe('isOnlyTeammateMessageBlocks', () => { + it('returns true for a single block', () => { + expect( + isOnlyTeammateMessageBlocks( + 'Hello' + ) + ).toBe(true); + }); + + it('returns true for multiple blocks with whitespace', () => { + const text = [ + 'X', + ' ', + 'Y', + ].join('\n'); + expect(isOnlyTeammateMessageBlocks(text)).toBe(true); + }); + + it('returns false when there is also regular text', () => { + const text = + 'Hello\nX'; + expect(isOnlyTeammateMessageBlocks(text)).toBe(false); + }); + + it('returns false for plain text', () => { + expect(isOnlyTeammateMessageBlocks('Just a normal message')).toBe(false); + }); +}); + +describe('isThoughtProtocolNoise', () => { + it('detects idle_notification JSON', () => { + expect( + isThoughtProtocolNoise('{"type":"idle_notification","message":"alice is idle"}') + ).toBe(true); + }); + + it('detects shutdown_request JSON', () => { + expect( + isThoughtProtocolNoise('{"type":"shutdown_request","reason":"done"}') + ).toBe(true); + }); + + it('detects shutdown_approved JSON', () => { + expect(isThoughtProtocolNoise('{"type":"shutdown_approved"}')).toBe(true); + }); + + it('detects teammate_terminated JSON', () => { + expect(isThoughtProtocolNoise('{"type":"teammate_terminated"}')).toBe(true); + }); + + it('detects pure teammate-message XML', () => { + expect( + isThoughtProtocolNoise( + 'Hello' + ) + ).toBe(true); + }); + + it('returns false for normal text', () => { + expect(isThoughtProtocolNoise('Reviewing the PR now.')).toBe(false); + }); + + it('returns false for non-noise JSON', () => { + expect( + isThoughtProtocolNoise('{"type":"message","message":"Hello from lead"}') + ).toBe(false); + }); + + it('returns false for text with teammate-message mixed with content', () => { + expect( + isThoughtProtocolNoise( + 'Starting work.\nX' + ) + ).toBe(false); + }); +}); + +describe('isInboxNoiseMessage', () => { + it('detects idle_notification', () => { + expect(isInboxNoiseMessage('{"type":"idle_notification"}')).toBe(true); + }); + + it('does not flag regular JSON messages', () => { + expect(isInboxNoiseMessage('{"type":"message","text":"hi"}')).toBe(false); + }); + + it('does not flag plain text', () => { + expect(isInboxNoiseMessage('Hello world')).toBe(false); + }); +});