Merge pull request #25 from 777genius/dev
feat: UI improvements, bug fixes, and protocol noise filtering
This commit is contained in:
commit
685f638b3b
58 changed files with 1372 additions and 1326 deletions
|
|
@ -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.
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -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
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
25
package.json
25
package.json
|
|
@ -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"
|
||||
|
|
|
|||
2010
pnpm-lock.yaml
2010
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
||||
|
|
|
|||
|
|
@ -401,7 +401,7 @@ export class SkillPlanService {
|
|||
if (entries.length > 0) {
|
||||
return;
|
||||
}
|
||||
await fs.rmdir(nextDir);
|
||||
await fs.rm(nextDir, { recursive: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { JSX } from 'react';
|
||||
/**
|
||||
* Empty state for ChatHistory when no conversation exists.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import React, { type JSX } from 'react';
|
||||
|
||||
import {
|
||||
getHighlightProps,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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)]">
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -113,7 +113,6 @@ export const MarkdownSplitView = React.memo(function MarkdownSplitView({
|
|||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Preview pane */}
|
||||
<div className="flex-1 overflow-hidden bg-surface">
|
||||
<MarkdownPreviewPane
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Component, type ReactNode } from 'react';
|
||||
import { Component, type JSX, type ReactNode } from 'react';
|
||||
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
* ```
|
||||
*/
|
||||
|
||||
import { createContext, type ReactNode } from 'react';
|
||||
import { createContext, type JSX, type ReactNode } from 'react';
|
||||
|
||||
// =============================================================================
|
||||
// Context Definition
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ interface UseContinuousScrollNavOptions {
|
|||
|
||||
interface UseContinuousScrollNavReturn {
|
||||
scrollToFile: (filePath: string) => void;
|
||||
isProgrammaticScroll: RefObject<boolean>;
|
||||
isProgrammaticScroll: RefObject<boolean | null>;
|
||||
}
|
||||
|
||||
export function useContinuousScrollNav(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
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