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:
parent
7298cffaf5
commit
94fc564bf5
21 changed files with 442 additions and 82 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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)]">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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)]" />,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
126
test/shared/utils/inboxNoise.test.ts
Normal file
126
test/shared/utils/inboxNoise.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue