feat: UI improvements, bug fixes, and protocol noise filtering

- Fix incorrect error message when attaching files to team lead while team is offline
- Kanban columns: color only on headers, body with 30% alpha tint per user preference
- Worktree projects now correctly detected on Dashboard via path-based detection
- Filter raw protocol messages (idle_notification, teammate-message) from lead thoughts
- Consistent text styles in Attachments section (From original message / From comments)
- Secondary sort for teams by lastActivity timestamp with alphabetical fallback
- Remove colored background from team cards, keep only left border
- Dynamic member color in Add Members dialog based on next available palette color
- Stylized @-mentions in task comments with colored MemberBadge
- Refactor CLI env resolution to shared utility
This commit is contained in:
iliya 2026-03-24 17:47:15 +02:00
parent 7298cffaf5
commit 94fc564bf5
21 changed files with 442 additions and 82 deletions

View file

@ -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,

View file

@ -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);
}
// ---------------------------------------------------------------------------

View file

@ -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'],
});

View file

@ -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,
}
: {}),
};
}

View file

@ -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)}

View file

@ -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 ? (
<div
className="pointer-events-none absolute inset-0 z-0 rounded-lg"
style={{ backgroundColor: getThemedBadge(teamColorSet, isLight) }}
/>
) : null}
<div
className={
teamColorSet ? 'relative z-10 flex flex-1 flex-col' : 'flex flex-1 flex-col'
}
>
<div className="flex flex-1 flex-col">
<div className="flex items-start justify-between">
<div className="flex min-w-0 flex-1 items-center gap-2">
<h3 className="truncate text-sm font-semibold text-[var(--color-text)]">

View file

@ -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;

View file

@ -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 (<teammate-message> 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(

View file

@ -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 = ({
>
<div className="flex flex-col items-center gap-1.5 text-red-400">
<Ban size={24} />
<span className="text-xs font-medium">Files can only be sent to the team lead</span>
<span className="text-xs font-medium">
{rejectionReason ?? 'Files can only be sent to the team lead'}
</span>
</div>
</div>
);

View file

@ -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<MemberDraft[]>(() => buildInitialDrafts(existingNames));
const [error, setError] = useState<string | null>(null);
@ -158,6 +161,7 @@ export const AddMemberDialog = ({
showJsonEditor={false}
draftKeyPrefix={`addMember:${teamName}`}
projectPath={projectPath}
existingMembers={existingMembers}
/>
</div>

View file

@ -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}
>
<DropZoneOverlay active={isDragOver} rejected={!supportsAttachments} />
<DropZoneOverlay
active={isDragOver}
rejected={!supportsAttachments}
rejectionReason={attachmentRestrictionReason}
/>
<DialogHeader>
<DialogTitle>Send Message</DialogTitle>

View file

@ -1315,10 +1315,12 @@ const CommentImagesGrid = ({
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
return (
<div className="mt-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-[10px] text-[var(--color-text-muted)]">
<MessageSquare size={10} />
From comments
<div className="mt-3 space-y-1">
<div className="flex items-center gap-1.5">
<MessageSquare size={12} className="text-[var(--color-text-muted)]" />
<span className="text-[11px] font-medium text-[var(--color-text-muted)]">
From comments
</span>
</div>
<div className="flex flex-wrap gap-2">
{items.map((item) => (

View file

@ -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: <ClipboardList size={14} className="shrink-0 text-[var(--color-text-muted)]" />,
},
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: <PlayCircle size={14} className="shrink-0 text-[var(--color-text-muted)]" />,
},
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: <CheckCircle2 size={14} className="shrink-0 text-[var(--color-text-muted)]" />,
},
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: <Eye size={14} className="shrink-0 text-[var(--color-text-muted)]" />,
},
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: <ShieldCheck size={14} className="shrink-0 text-[var(--color-text-muted)]" />,
},
};

View file

@ -47,7 +47,7 @@ export const KanbanColumn = ({
)}
<header
className={cn(
'border-b border-[var(--color-border)] px-3 py-2',
'rounded-t-md border-b border-[var(--color-border)] px-3 py-2',
headerClassName,
headerDragClassName
)}

View file

@ -72,6 +72,8 @@ export interface MembersEditorSectionProps {
headerExtra?: React.ReactNode;
/** When true, hides member rows and action buttons (label + headerExtra still visible) */
hideContent?: boolean;
/** Existing team members — used to reserve their colors so drafts get the next available ones */
existingMembers?: readonly { name: string; color?: string; removedAt?: number | string | null }[];
}
export const MembersEditorSection = ({
@ -87,6 +89,7 @@ export const MembersEditorSection = ({
teamSuggestions,
headerExtra,
hideContent = false,
existingMembers,
}: MembersEditorSectionProps): React.JSX.Element => {
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),

View file

@ -36,15 +36,37 @@ export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
};
}
interface ExistingMemberColorInput {
name: string;
color?: string;
removedAt?: number | string | null;
}
export function buildMemberDraftColorMap(
members: readonly Pick<MemberDraft, 'name'>[]
members: readonly Pick<MemberDraft, 'name'>[],
existingMembers?: readonly ExistingMemberColorInput[]
): Map<string, string> {
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<string, string>();
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. */

View file

@ -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 = ({
</div>
<div className="relative">
<DropZoneOverlay active={isDragOver} rejected={!supportsAttachments} />
<DropZoneOverlay
active={isDragOver}
rejected={!supportsAttachments}
rejectionReason={attachmentRestrictionReason}
/>
<MentionableTextarea
ref={textareaRef}
id={`compose-${teamName}`}

View file

@ -36,3 +36,45 @@ export function isInboxNoiseMessage(text: string): boolean {
const type = getInboxJsonType(text);
return !!type && INBOX_NOISE_SET.has(type);
}
// ---------------------------------------------------------------------------
// Teammate-message XML block detection & stripping
// ---------------------------------------------------------------------------
const TEAMMATE_MESSAGE_BLOCK_RE = /<teammate-message\s[^>]*>[\s\S]*?<\/teammate-message>/g;
/**
* Removes `<teammate-message>` 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 `<teammate-message>` 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 `<teammate-message>` XML blocks
*/
export function isThoughtProtocolNoise(text: string): boolean {
if (isInboxNoiseMessage(text)) return true;
if (isOnlyTeammateMessageBlocks(text)) return true;
return false;
}

View file

@ -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(),

View file

@ -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 <teammate-message> XML blocks from lead thoughts', () => {
expect(
isLeadThought({
from: 'team-lead',
text: '<teammate-message teammate_id="researcher" color="#4CAF50" summary="Done">Task completed</teammate-message>',
timestamp: '2026-03-08T00:00:00.000Z',
read: true,
source: 'lead_session',
})
).toBe(false);
});
it('filters out multiple <teammate-message> blocks with whitespace', () => {
const text = [
'<teammate-message teammate_id="alice" color="#f00" summary="hi">Hello</teammate-message>',
'',
'<teammate-message teammate_id="bob" color="#0f0" summary="ok">OK</teammate-message>',
].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);
});
});

View file

@ -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 =
'<teammate-message teammate_id="alice" color="#f00" summary="hi">Hello world</teammate-message>';
expect(stripTeammateMessageBlocks(text)).toBe('');
});
it('removes multiple teammate-message blocks', () => {
const text = [
'<teammate-message teammate_id="alice" color="#f00" summary="hi">Hello</teammate-message>',
'<teammate-message teammate_id="bob" color="#0f0" summary="ok">OK</teammate-message>',
].join('\n');
expect(stripTeammateMessageBlocks(text)).toBe('');
});
it('preserves normal text around teammate-message blocks', () => {
const text =
'Before\n<teammate-message teammate_id="alice" color="#f00" summary="hi">Hello</teammate-message>\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(
'<teammate-message teammate_id="alice" color="#f00" summary="hi">Hello</teammate-message>'
)
).toBe(true);
});
it('returns true for multiple blocks with whitespace', () => {
const text = [
'<teammate-message teammate_id="a" color="" summary="">X</teammate-message>',
' ',
'<teammate-message teammate_id="b" color="" summary="">Y</teammate-message>',
].join('\n');
expect(isOnlyTeammateMessageBlocks(text)).toBe(true);
});
it('returns false when there is also regular text', () => {
const text =
'Hello\n<teammate-message teammate_id="a" color="" summary="">X</teammate-message>';
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(
'<teammate-message teammate_id="alice" color="#f00" summary="hi">Hello</teammate-message>'
)
).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.\n<teammate-message teammate_id="a" color="" summary="">X</teammate-message>'
)
).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);
});
});