Merge pull request #25 from 777genius/dev

feat: UI improvements, bug fixes, and protocol noise filtering
This commit is contained in:
Илия 2026-03-24 21:57:42 +02:00 committed by GitHub
commit 685f638b3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 1372 additions and 1326 deletions

View file

@ -21,7 +21,7 @@ Key capabilities:
100% free, open source. No API keys. No configuration. Runs entirely locally.
## Tech Stack
Electron 28.x, React 18.x, TypeScript 5.x, Tailwind CSS 3.x, Zustand 4.x
Electron 40.x, React 19.x, TypeScript 5.x, Tailwind CSS 3.x, Zustand 4.x
## Commands
Always use pnpm (not npm/yarn) for this project.

View file

@ -243,7 +243,7 @@ Yes. Run multiple teams in one project or across different projects, even simult
## Tech stack
Electron 40, React 18, TypeScript 5, Tailwind CSS 3, Zustand 4. Data from `~/.claude/` (session logs, todos, tasks). No cloud backend — everything runs locally.
Electron 40, React 19, TypeScript 5, Tailwind CSS 3, Zustand 4. Data from `~/.claude/` (session logs, todos, tasks). No cloud backend — everything runs locally.
<details>
<summary><strong>Build from source</strong></summary>
@ -295,14 +295,13 @@ pnpm dist # macOS + Windows + Linux
## Roadmap
- [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
- [ ] Remote agent execution via SSH: launch and manage agent teams on remote machines over SSH (stream-json protocol over SSH channel, SFTP-based file monitoring for tasks/inboxes/config)
- [ ] 2 modes: current (agent teams), and a new mode: regular subagents (no communication between them)
- [ ] Visual workflow editor ([@xyflow/react](https://github.com/xyflow/xyflow)) for building and orchestrating agent pipelines with drag & drop
- [ ] Planning mode to organize agent plans before execution
- [ ] Curate what context each agent sees (files, docs, MCP servers, skills)
- [ ] Visual workflow editor ([@xyflow/react](https://github.com/xyflow/xyflow)) for building and orchestrating agent pipelines with drag & drop
- [ ] Multi-model support: proxy layer to use other popular LLMs (GPT, Gemini, DeepSeek, Llama, etc.), including offline/local models
- [ ] Attach any files to messages/comments/tasks
- [ ] Remote agent execution via SSH: launch and manage agent teams on remote machines over SSH (stream-json protocol over SSH channel, SFTP-based file monitoring for tasks/inboxes/config)
- [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
- [ ] 2 modes: current (agent teams), and a new mode: regular subagents (no communication between them)
- [ ] Curate what context each agent sees (files, docs, MCP servers, skills)
- [ ] Slash commands
---

View file

@ -1,4 +1,4 @@
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import { defineConfig } from 'electron-vite'
import { sentryVitePlugin } from '@sentry/vite-plugin'
import react from '@vitejs/plugin-react'
import { readFileSync } from 'fs'
@ -51,9 +51,6 @@ const sentryPlugins = process.env.SENTRY_AUTH_TOKEN
export default defineConfig({
main: {
plugins: [
externalizeDepsPlugin({
exclude: bundledDeps
}),
nativeModuleStub(),
...sentryPlugins,
],
@ -71,6 +68,9 @@ export default defineConfig({
}
},
build: {
externalizeDeps: {
exclude: bundledDeps
},
sourcemap: 'hidden',
outDir: 'dist-electron/main',
rollupOptions: {
@ -93,7 +93,6 @@ export default defineConfig({
}
},
preload: {
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
'@preload': resolve(__dirname, 'src/preload'),

View file

@ -17,7 +17,7 @@ onMounted(() => {
});
const flagIconMap: Record<string, string> = {
en: "circle-flags:us",
en: "circle-flags:gb",
zh: "circle-flags:cn",
es: "circle-flags:es",
hi: "circle-flags:in",

View file

@ -112,11 +112,11 @@
"@sentry/electron": "^7.10.0",
"@sentry/react": "^10.45.0",
"@tanstack/react-virtual": "^3.10.8",
"@tiptap/extension-placeholder": "^3.20.1",
"@tiptap/markdown": "^3.20.1",
"@tiptap/pm": "^3.20.1",
"@tiptap/react": "^3.20.1",
"@tiptap/starter-kit": "^3.20.1",
"@tiptap/extension-placeholder": "^3.20.4",
"@tiptap/markdown": "^3.20.4",
"@tiptap/pm": "^3.20.4",
"@tiptap/react": "^3.20.4",
"@tiptap/starter-kit": "^3.20.4",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
@ -135,13 +135,13 @@
"highlight.js": "^11.11.1",
"idb-keyval": "^6.2.2",
"isbinaryfile": "^6.0.0",
"lucide-react": "^0.562.0",
"lucide-react": "^0.577.0",
"mdast-util-to-hast": "^13.2.1",
"mermaid": "^11.12.3",
"node-diff3": "^3.2.0",
"node-pty": "^1.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-grid-layout": "^2.2.2",
"react-markdown": "^10.1.0",
"react-resizable": "^3.1.3",
@ -170,15 +170,15 @@
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"@types/node": "^25.0.7",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/ssh2": "^1.15.5",
"@vitejs/plugin-react": "^4.3.1",
"@vitest/coverage-v8": "^3.1.4",
"autoprefixer": "^10.4.17",
"electron": "^40.3.0",
"electron-builder": "^25.1.8",
"electron-vite": "^2.3.0",
"electron-builder": "^26.8.1",
"electron-vite": "^5.0.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
@ -244,6 +244,7 @@
},
"mac": {
"category": "public.app-category.developer-tools",
"minimumSystemVersion": "12.0",
"target": [
"dmg",
"zip"

File diff suppressed because it is too large Load diff

View file

@ -108,6 +108,13 @@ import type { TeamChangeEvent } from '@shared/types';
const logger = createLogger('App');
startEventLoopLagMonitor();
// Windows: set AppUserModelId early so native notifications show the correct
// application title instead of the default "electron.app.{name}" identifier.
// Must match the appId in electron-builder config (package.json → build.appId).
if (process.platform === 'win32') {
app.setAppUserModelId('com.agent-teams.app');
}
// --- Team message notification tracking ---
const teamInboxReader = new TeamInboxReader();
const sentMessagesStore = new TeamSentMessagesStore();

View file

@ -15,6 +15,18 @@
* - SessionSearcher: Search functionality
*/
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 {
type PaginatedSessionsResult,
type Project,
@ -25,6 +37,7 @@ import {
type SessionMetadataLevel,
type SessionsByIdsOptions,
type SessionsPaginationOptions,
type WorktreeSource,
} from '@main/types';
import {
analyzeSessionFileMetadata,
@ -45,6 +58,7 @@ import {
import { createLogger } from '@shared/utils/logger';
import * as path from 'path';
import { configManager } from '../infrastructure/ConfigManager';
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,
@ -253,7 +310,6 @@ export class ProjectScanner {
});
// 3. Merge custom project paths from config (persisted "Select Folder" picks)
const { configManager } = await import('../infrastructure/ConfigManager');
const customPaths = configManager.getCustomProjectPaths();
const existingPaths = new Set(groups.flatMap((g) => g.worktrees.map((w) => w.path)));

View file

@ -401,7 +401,7 @@ export class SkillPlanService {
if (entries.length > 0) {
return;
}
await fs.rmdir(nextDir);
await fs.rm(nextDir, { recursive: true });
} catch {
return;
}

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 {
@ -82,20 +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();
return {
...process.env,
HOME: home,
USERPROFILE: home,
PATH: buildMergedCliPath(binaryPath),
};
}
/** `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();
@ -379,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

@ -180,7 +180,7 @@ export class TeamTaskAttachmentStore {
try {
const entries = await fs.promises.readdir(dir);
if (entries.length === 0) {
await fs.promises.rmdir(dir);
await fs.promises.rm(dir, { recursive: true });
}
} catch {
// ignore cleanup errors

View file

@ -1,22 +1,46 @@
/**
* 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 { buildMergedCliPath } from '@main/utils/cliPathMerge';
import { getClaudeBasePath } from '@main/utils/pathDecoder';
import { getCachedShellEnv, getShellPreferredHome } from '@main/utils/shellEnv';
import { userInfo } from 'os';
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

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { type JSX, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { isNearBottom, useAutoScrollBottom } from '@renderer/hooks/useAutoScrollBottom';
import { useTabNavigationController } from '@renderer/hooks/useTabNavigationController';

View file

@ -1,3 +1,4 @@
import type { JSX } from 'react';
/**
* Empty state for ChatHistory when no conversation exists.
*/

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { type JSX } from 'react';
import {
getHighlightProps,

View file

@ -1,3 +1,4 @@
import type { JSX } from 'react';
/**
* Loading skeleton for ChatHistory while conversation is loading.
* Industrial shimmer with organic line widths no generic pulse.

View file

@ -20,8 +20,8 @@ export const AssessmentBadge = ({ assessment, metricKey }: AssessmentBadgeProps)
const [showTooltip, setShowTooltip] = useState(false);
const [tooltipPos, setTooltipPos] = useState({ top: 0, left: 0 });
const badgeRef = useRef<HTMLSpanElement>(null);
const enterTimer = useRef<ReturnType<typeof setTimeout>>();
const leaveTimer = useRef<ReturnType<typeof setTimeout>>();
const enterTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
const leaveTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleMouseEnter = useCallback(() => {
if (!explanation) return;

View file

@ -37,7 +37,7 @@ export const AdvancedSection = ({
const checkForUpdates = useStore((s) => s.checkForUpdates);
// Auto-revert "not-available" / "error" status back to idle after a brief display
const revertTimerRef = useRef<ReturnType<typeof setTimeout>>();
const revertTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
if (updateStatus === 'not-available' || updateStatus === 'error') {
revertTimerRef.current = setTimeout(() => {

View file

@ -63,8 +63,8 @@ export const ConfigEditorDialog = ({
}: ConfigEditorDialogProps): React.JSX.Element | null => {
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>();
const savedRevertTimerRef = useRef<ReturnType<typeof setTimeout>>();
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const savedRevertTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
const [jsonError, setJsonError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);

View file

@ -205,7 +205,7 @@ export const ProvisioningProgressBlock = ({
<Button
variant="ghost"
size="sm"
className="h-6 w-6 shrink-0 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
className="size-6 shrink-0 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={onDismiss}
>
<X size={12} />
@ -217,7 +217,7 @@ export const ProvisioningProgressBlock = ({
<Button
variant="ghost"
size="sm"
className="h-6 w-6 shrink-0 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
className="size-6 shrink-0 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={onDismiss}
>
<X size={12} />

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

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { type JSX, useCallback, useEffect, useRef, useState } from 'react';
import type { CSSProperties, MutableRefObject, PropsWithChildren, Ref } from 'react';

View file

@ -1,4 +1,13 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import {
type JSX,
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@ -18,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';
@ -51,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

@ -1,4 +1,4 @@
import { memo, useCallback, useMemo } from 'react';
import { type JSX, memo, useCallback, useMemo } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { CopyButton } from '@renderer/components/common/CopyButton';
@ -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

@ -113,7 +113,6 @@ export const MarkdownSplitView = React.memo(function MarkdownSplitView({
onMouseDown={handleMouseDown}
/>
)}
{/* Preview pane */}
<div className="flex-1 overflow-hidden bg-surface">
<MarkdownPreviewPane

View file

@ -50,7 +50,7 @@ export const SearchInFilesPanel = ({
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// Monotonic request ID — prevents stale results from overwriting fresh ones
const requestIdRef = useRef(0);

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

@ -363,12 +363,24 @@ export const MemberLogsTab = ({
const previewOnline = useMemo((): boolean => {
if (!previewLog) return false;
// Primary signal: the session file is still being written to
if (previewLog.isOngoing) return true;
// Secondary: check message freshness with generous windows
// Determine the most recent activity timestamp from preview messages
const newest = previewMessages[0];
const newestMs = newest ? newest.timestamp.getTime() : 0;
// Fallback: use session start time when no preview messages exist
const lastActivityMs = newestMs || new Date(previewLog.startTime).getTime() || 0;
if (!lastActivityMs) return false;
const ageMs = Date.now() - lastActivityMs;
// isOngoing (file still being written) grants a longer freshness window,
// but does NOT bypass age checks — the file may be updated by system messages
// even while the agent is idle
if (previewLog.isOngoing) {
// Ongoing + in_progress: generous 3-minute window
if (taskStatus === 'in_progress') return ageMs <= 180_000;
// Ongoing + other status: moderate window
return ageMs <= 60_000;
}
// Not ongoing: check message freshness with tighter windows
if (!newest) return false;
const ageMs = Date.now() - newest.timestamp.getTime();
// Task actively in progress — agent may pause between visible outputs
if (taskStatus === 'in_progress') return ageMs <= 60_000;
// Completed/other tasks — shorter window

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

@ -150,7 +150,7 @@ export const ChangeReviewDialog = ({
const [selectionInfo, setSelectionInfo] = useState<EditorSelectionInfo | null>(null);
const [containerRect, setContainerRect] = useState<DOMRect>(new DOMRect());
const diffContentRef = useRef<HTMLDivElement>(null);
const selectionTimerRef = useRef<ReturnType<typeof setTimeout>>();
const selectionTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const activeSelectionFileRef = useRef<string | null>(null);
// EditorView map for all visible file editors

View file

@ -225,7 +225,7 @@ export const CodeMirrorDiffView = ({
const onContentChangedRef = useRef(onContentChanged);
const onViewChangeRef = useRef(onViewChange);
const onSelectionChangeRef = useRef(onSelectionChange);
const debounceTimer = useRef<ReturnType<typeof setTimeout>>();
const debounceTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
onAcceptRef.current = onHunkAccepted;
onRejectRef.current = onHunkRejected;

View file

@ -59,9 +59,9 @@ interface ContinuousScrollViewProps {
collapsedFiles?: Set<string>;
onToggleCollapse?: (filePath: string) => void;
onVisibleFileChange: (filePath: string) => void;
scrollContainerRef: React.RefObject<HTMLDivElement>;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
editorViewMapRef: React.MutableRefObject<Map<string, EditorView>>;
isProgrammaticScroll: React.RefObject<boolean>;
isProgrammaticScroll: React.RefObject<boolean | null>;
teamName: string;
memberName: string | undefined;
fetchFileContent: (

View file

@ -1,4 +1,4 @@
import { Component, type ReactNode } from 'react';
import { Component, type JSX, type ReactNode } from 'react';
import { AlertTriangle } from 'lucide-react';

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { type JSX, useCallback, useEffect, useMemo, useState } from 'react';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { type JSX, useState } from 'react';
import { cn } from '@renderer/lib/utils';
import { AlertTriangle, ChevronRight, Info, ShieldCheck, X } from 'lucide-react';

View file

@ -17,7 +17,7 @@
* ```
*/
import { createContext, type ReactNode } from 'react';
import { createContext, type JSX, type ReactNode } from 'react';
// =============================================================================
// Context Definition

View file

@ -61,6 +61,7 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
// eslint-disable-next-line react-hooks/refs -- synchronous ref sync during render is intentional to avoid stale key in callbacks
keyRef.current = persistenceKey;
const onUnsupportedRef = useRef(options?.onUnsupportedFiles);
// eslint-disable-next-line react-hooks/refs -- synchronous ref sync during render is intentional to avoid stale callback in handlers
onUnsupportedRef.current = options?.onUnsupportedFiles;
// Sync ref with state

View file

@ -40,7 +40,7 @@ interface UseAutoScrollBottomOptions {
* ref instead of creating its own. Useful when the ref needs to be shared
* with other hooks (e.g., navigation coordinator).
*/
externalRef?: React.RefObject<HTMLDivElement>;
externalRef?: React.RefObject<HTMLDivElement | null>;
/**
* When this value changes, reset isAtBottom state to true.
@ -56,7 +56,7 @@ interface UseAutoScrollBottomReturn {
/**
* Ref to attach to the scroll container element.
*/
scrollContainerRef: React.RefObject<HTMLDivElement>;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
/**
* Get whether the user is currently at the bottom of the scroll container.

View file

@ -8,7 +8,7 @@ interface UseContinuousScrollNavOptions {
interface UseContinuousScrollNavReturn {
scrollToFile: (filePath: string) => void;
isProgrammaticScroll: RefObject<boolean>;
isProgrammaticScroll: RefObject<boolean | null>;
}
export function useContinuousScrollNav(

View file

@ -47,8 +47,8 @@ export function useLazyFileContent(options: UseLazyFileContentOptions): UseLazyF
}, []);
// Refs for loadFile/processQueue to avoid circular useCallback deps
const loadFileRef = useRef<(fp: string) => Promise<void>>();
const processQueueRef = useRef<() => void>();
const loadFileRef = useRef<(fp: string) => Promise<void>>(undefined);
const processQueueRef = useRef<() => void>(undefined);
loadFileRef.current = async (filePath: string) => {
if (!shouldLoad(filePath)) return;

View file

@ -1,4 +1,4 @@
import { useRef } from 'react';
import { useCallback, useRef } from 'react';
/**
* Provides a stable ref callback for the comments container.
@ -19,9 +19,9 @@ export function useMarkCommentsRead(
const nodeRef = useRef<HTMLElement | null>(null);
// Stable ref callback (no dependencies — just stores the node)
const refCallback = useRef((node: HTMLElement | null) => {
const refCallback = useCallback((node: HTMLElement | null) => {
nodeRef.current = node;
}).current;
}, []);
return refCallback;
}

View file

@ -64,15 +64,15 @@ interface UseTabNavigationControllerOptions {
/** Tab ID for consuming navigation */
tabId: string;
/** Refs to AI group DOM elements */
aiGroupRefs: React.MutableRefObject<Map<string, HTMLElement>>;
aiGroupRefs: React.RefObject<Map<string, HTMLElement>>;
/** Refs to individual chat item DOM elements */
chatItemRefs: React.MutableRefObject<Map<string, HTMLElement>>;
chatItemRefs: React.RefObject<Map<string, HTMLElement>>;
/** Refs to individual tool item DOM elements */
toolItemRefs: React.MutableRefObject<Map<string, HTMLElement>>;
toolItemRefs: React.RefObject<Map<string, HTMLElement>>;
/** Function to expand an AI group (per-tab state) */
expandAIGroup: (groupId: string) => void;
/** Ref to scroll container */
scrollContainerRef: React.RefObject<HTMLDivElement>;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
/** Height of sticky elements at top of scroll container */
stickyOffset?: number;
/** Optional helper to ensure a target group is mounted (e.g., virtualized lists) */

View file

@ -4,7 +4,7 @@ interface UseVisibleAIGroupOptions {
onVisibleChange: (aiGroupId: string) => void;
threshold?: number; // Default 0.5
/** Optional scroll container to observe against (important for nested scroll areas). */
rootRef?: RefObject<HTMLElement>;
rootRef?: RefObject<HTMLElement | null>;
}
interface UseVisibleAIGroupReturn {

View file

@ -3,7 +3,7 @@ import { type RefObject, useCallback, useEffect, useRef } from 'react';
interface UseVisibleFileSectionOptions {
onVisibleFileChange: (filePath: string) => void;
scrollContainerRef: RefObject<HTMLElement | null>;
isProgrammaticScroll: RefObject<boolean>;
isProgrammaticScroll: RefObject<boolean | null>;
}
interface UseVisibleFileSectionReturn {
@ -18,7 +18,7 @@ export function useVisibleFileSection(
const visibleFilePaths = useRef<Set<string>>(new Set());
const elementRefs = useRef<Map<string, HTMLElement>>(new Map());
const observerRef = useRef<IntersectionObserver | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const updateTopmostVisible = useCallback(() => {
if (isProgrammaticScroll.current) return;

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

@ -1,6 +1,5 @@
import React from 'react';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useVisibleAIGroup } from '../../../src/renderer/hooks/useVisibleAIGroup';
@ -23,8 +22,9 @@ describe('useVisibleAIGroup', () => {
});
it('uses provided rootRef as IntersectionObserver root', async () => {
const observerSpy = vi.fn((cb: IntersectionObserverCallback, opts?: IntersectionObserverInit) =>
new FakeIntersectionObserver(cb, opts)
const observerSpy = vi.fn(
(cb: IntersectionObserverCallback, opts?: IntersectionObserverInit) =>
new FakeIntersectionObserver(cb, opts)
);
vi.stubGlobal('IntersectionObserver', observerSpy as unknown as typeof IntersectionObserver);

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