From 70fdc2537a7467657877104b7fde69d2aaa9663b Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 5 Mar 2026 17:27:09 +0200 Subject: [PATCH] feat: enhance inbox message handling and UI components - Introduced functions to identify and filter out internal coordination noise from inbox messages, improving notification relevance. - Updated TeamProvisioningService to automatically mark noise messages as read, reducing clutter for team leads. - Enhanced TeamDetailView and MessagesFilterPopover to allow users to toggle visibility of noise messages in the UI. - Refactored various components to improve the handling of task attachments and message serialization, ensuring better user experience. - Improved error handling and state management in session detail fetching, allowing for real-time updates without UI disruptions. --- src/main/index.ts | 58 ++-- src/main/ipc/teams.ts | 6 - .../services/team/TeamAgentToolsInstaller.ts | 52 +++- .../services/team/TeamProvisioningService.ts | 20 +- .../team/ClaudeLogsFilterPopover.tsx | 179 +++++++++++ .../components/team/ClaudeLogsSection.tsx | 278 +++++++++++++++++- .../components/team/CliLogsRichView.tsx | 27 +- .../components/team/TeamDetailView.tsx | 33 ++- .../team/dialogs/SendMessageDialog.tsx | 10 +- .../team/dialogs/TaskAttachments.tsx | 27 +- .../team/messages/MessagesFilterPopover.tsx | 24 +- .../store/slices/sessionDetailSlice.ts | 52 ++-- src/renderer/utils/contextMath.ts | 2 +- src/renderer/utils/streamJsonParser.ts | 15 +- src/shared/utils/inboxNoise.ts | 38 +++ 15 files changed, 697 insertions(+), 124 deletions(-) create mode 100644 src/renderer/components/team/ClaudeLogsFilterPopover.tsx create mode 100644 src/shared/utils/inboxNoise.ts diff --git a/src/main/index.ts b/src/main/index.ts index 7adf3dc0..eb0084e6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -34,6 +34,7 @@ import { getTrafficLightPositionForZoom, WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, } from '@shared/constants'; +import { isInboxNoiseMessage, parseInboxJson } from '@shared/utils/inboxNoise'; import { createLogger } from '@shared/utils/logger'; import { app, BrowserWindow } from 'electron'; import { existsSync } from 'fs'; @@ -132,42 +133,6 @@ async function resolveTeamDisplayName(teamName: string): Promise { return resolved; } -/** - * Inbox message types that are internal coordination noise — not useful as OS notifications. - * Matches renderer-side NOISE_TYPES in agentMessageFormatting.ts. - */ -const INBOX_NOISE_TYPES = new Set([ - 'idle_notification', - 'shutdown_approved', - 'teammate_terminated', - 'shutdown_request', -]); - -/** - * Parses an inbox message text that may be serialized JSON. - * Returns null if not valid JSON or not an object. - */ -function parseInboxJson(text: string): Record | null { - const trimmed = text.trim(); - if (!trimmed.startsWith('{')) return null; - try { - const parsed = JSON.parse(trimmed) as unknown; - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return parsed as Record; - } - } catch { - // not JSON — plain text message - } - return null; -} - -/** Returns true if the inbox message text is a noise type that should not trigger an OS notification. */ -function isInboxNoiseMessage(text: string): boolean { - const parsed = parseInboxJson(text); - if (!parsed) return false; - return typeof parsed.type === 'string' && INBOX_NOISE_TYPES.has(parsed.type); -} - /** * Extracts human-readable summary and body from an inbox message. * Handles both plain text and serialized JSON ({"type":"message","content":"...","summary":"..."}). @@ -448,11 +413,22 @@ function wireFileWatcherEvents(context: ServiceContext): void { // --- Inbox change events: relay to lead + native OS notifications --- if (row.type === 'inbox') { - // Auto-relay direct messages to live team lead process (no UI dependency). - if (teamProvisioningService.isTeamAlive(teamName)) { - void teamProvisioningService - .relayLeadInboxMessages(teamName) - .catch((e: unknown) => logger.warn(`[FileWatcher] relay failed for ${teamName}: ${e}`)); + // Auto-relay ONLY lead-inbox changes into the live lead process. + // (Relaying on *any* inbox change causes the lead to process irrelevant status noise.) + if (teamProvisioningService.isTeamAlive(teamName) && detail.startsWith('inboxes/')) { + const match = /^inboxes\/(.+)\.json$/.exec(detail); + if (match && teamDataService) { + const inboxName = match[1]; + void teamDataService + .getLeadMemberName(teamName) + .then((leadName) => { + if (!leadName || inboxName !== leadName) return; + return teamProvisioningService.relayLeadInboxMessages(teamName); + }) + .catch((e: unknown) => + logger.warn(`[FileWatcher] relay failed for ${teamName}: ${e}`) + ); + } } // Show native OS notification for new inbox messages (debounced per inbox). diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index db3142f6..5c85629b 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -392,12 +392,6 @@ async function handleGetData( const provisioning = getTeamProvisioningService(); const isAlive = provisioning.isTeamAlive(tn); - if (isAlive) { - void provisioning - .relayLeadInboxMessages(tn) - .catch((e: unknown) => logger.warn(`Relay failed for ${tn}: ${e}`)); - } - const displayName = data.config.name || tn; const projectPath = data.config.projectPath; diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index c13ac263..7868cc1c 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -46,6 +46,29 @@ function die(message, code = 1) { process.exit(code); } +function isSafePathSegment(value) { + const v = String(value == null ? '' : value); + if (v.length === 0 || v.trim().length === 0) return false; + if (v === '.' || v === '..') return false; + if (v.includes('/') || v.includes('\\\\')) return false; + if (v.includes('..')) return false; + if (v.includes('\0')) return false; + return true; +} + +function assertSafePathSegment(label, value) { + const v = String(value == null ? '' : value); + if (!isSafePathSegment(v)) { + die('Invalid ' + String(label)); + } + return v; +} + +function getTaskJsonPath(paths, taskId) { + const id = assertSafePathSegment('taskId', taskId); + return path.join(paths.tasksDir, id + '.json'); +} + function parseArgs(argv) { const out = { _: [], flags: {} }; for (let i = 0; i < argv.length; i++) { @@ -128,7 +151,7 @@ function getTeamName(flags) { (typeof flags.team === 'string' && flags.team.trim()) || (typeof flags['teamName'] === 'string' && flags['teamName'].trim()) || ''; - if (explicit) return explicit; + if (explicit) return assertSafePathSegment('team', explicit); const inferred = inferTeamNameFromScriptPath(); if (inferred) return inferred; die('Missing --team (and could not infer team name from script path)'); @@ -258,7 +281,8 @@ function detectMimeTypeFromPathAndHeader(filePath, filename) { } function getTaskAttachmentsDir(paths, taskId) { - return path.join(paths.teamDir, TASK_ATTACHMENTS_DIR, String(taskId)); + const id = assertSafePathSegment('taskId', taskId); + return path.join(paths.teamDir, TASK_ATTACHMENTS_DIR, id); } function getStoredAttachmentPath(paths, taskId, attachmentId, filename) { @@ -421,8 +445,9 @@ function normalizeColumn(value) { function getPaths(flags, teamName) { const claudeDir = getClaudeDir(flags); - const teamDir = path.join(claudeDir, 'teams', teamName); - const tasksDir = path.join(claudeDir, 'tasks', teamName); + const safeTeam = assertSafePathSegment('team', teamName); + const teamDir = path.join(claudeDir, 'teams', safeTeam); + const tasksDir = path.join(claudeDir, 'tasks', safeTeam); const kanbanPath = path.join(teamDir, 'kanban-state.json'); const processesPath = path.join(teamDir, 'processes.json'); return { claudeDir, teamDir, tasksDir, kanbanPath, processesPath }; @@ -438,7 +463,7 @@ function inferLeadName(paths) { } function readTask(paths, taskId) { - const taskPath = path.join(paths.tasksDir, String(taskId) + '.json'); + const taskPath = getTaskJsonPath(paths, taskId); const task = readJson(taskPath, null); if (!task) die('Task not found: ' + String(taskId)); return { taskPath, task }; @@ -607,13 +632,13 @@ function parseIdList(value) { function taskExists(paths, taskId) { try { - fs.accessSync(path.join(paths.tasksDir, String(taskId) + '.json'), fs.constants.F_OK); + fs.accessSync(getTaskJsonPath(paths, taskId), fs.constants.F_OK); return true; } catch (e) { return false; } } function readTaskObject(paths, taskId) { - var taskPath = path.join(paths.tasksDir, String(taskId) + '.json'); + var taskPath = getTaskJsonPath(paths, taskId); var t = readJson(taskPath, null); if (!t) die('Task not found: #' + taskId); return { task: t, taskPath: taskPath }; @@ -628,7 +653,8 @@ function wouldCreateBlockCycle(paths, sourceId, targetId) { if (visited[current]) continue; visited[current] = true; try { - var t = readJson(path.join(paths.tasksDir, current + '.json'), null); + if (!isSafePathSegment(current)) continue; + var t = readJson(getTaskJsonPath(paths, current), null); if (t && Array.isArray(t.blockedBy)) { for (var i = 0; i < t.blockedBy.length; i++) stack.push(String(t.blockedBy[i])); } @@ -730,7 +756,7 @@ function createTask(paths, flags) { let taskPath; while (true) { nextId = getNextTaskId(paths); - taskPath = path.join(paths.tasksDir, String(nextId) + '.json'); + taskPath = getTaskJsonPath(paths, nextId); var createdAt = nowIso(); task = { id: nextId, @@ -825,7 +851,8 @@ function sendInboxMessage(paths, teamName, flags) { const summary = typeof flags.summary === 'string' ? flags.summary : undefined; const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths); - const inboxPath = path.join(paths.teamDir, 'inboxes', String(to) + '.json'); + const safeTo = assertSafePathSegment('to', to); + const inboxPath = path.join(paths.teamDir, 'inboxes', safeTo + '.json'); ensureDir(path.dirname(inboxPath)); const messageId = makeId(); @@ -1026,7 +1053,8 @@ function taskBriefing(paths, teamName, flags) { var allTasks = []; for (var i = 0; i < ids.length; i++) { try { - var taskPath = path.join(paths.tasksDir, ids[i] + '.json'); + if (!isSafePathSegment(ids[i])) continue; + var taskPath = getTaskJsonPath(paths, ids[i]); var t = readJson(taskPath, null); if (t && !String(t.id).startsWith('_internal') && !(t.metadata && t.metadata._internal === true)) { try { t._mtime = fs.statSync(taskPath).mtime.toISOString(); } catch (_e) { t._mtime = ''; } @@ -1272,7 +1300,7 @@ async function main() { const tasks = []; for (const id of ids) { try { - tasks.push(readJson(path.join(paths.tasksDir, String(id) + '.json'), null)); + tasks.push(readJson(getTaskJsonPath(paths, id), null)); } catch {} } process.stdout.write(JSON.stringify(tasks.filter(Boolean), null, 2) + '\n'); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 71450578..1c00b132 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -19,6 +19,7 @@ import { } from '@shared/constants/agentBlocks'; import { getMemberColor } from '@shared/constants/memberColors'; import { resolveLanguageName } from '@shared/utils/agentLanguage'; +import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { createLogger } from '@shared/utils/logger'; import { spawn } from 'child_process'; import { randomUUID } from 'crypto'; @@ -2245,8 +2246,23 @@ export class TeamProvisioningService { if (unread.length === 0) return 0; + // Ignore (and auto-mark read) internal coordination noise like idle/shutdown messages. + // These frequently appear when teammates are idle/available and should not prompt + // the lead to respond with "No action needed." + const noiseUnread = unread.filter((m) => isInboxNoiseMessage(m.text)); + if (noiseUnread.length > 0) { + try { + await this.markInboxMessagesRead(teamName, leadName, noiseUnread); + } catch { + // best-effort + } + } + + const actionableUnread = unread.filter((m) => !isInboxNoiseMessage(m.text)); + if (actionableUnread.length === 0) return 0; + const MAX_RELAY = 10; - const batch = unread.slice(0, MAX_RELAY); + const batch = actionableUnread.slice(0, MAX_RELAY); const message = [ `You have new inbox messages addressed to you (team lead "${leadName}").`, @@ -3156,6 +3172,8 @@ export class TeamProvisioningService { this.relayedLeadInboxMessageIds.delete(run.teamName); this.relayedLeadInboxFallbackKeys.delete(run.teamName); this.liveLeadProcessMessages.delete(run.teamName); + // Remove from runs Map to free memory (stdoutBuffer, stderrBuffer, claudeLogLines) + this.runs.delete(run.runId); } /** diff --git a/src/renderer/components/team/ClaudeLogsFilterPopover.tsx b/src/renderer/components/team/ClaudeLogsFilterPopover.tsx new file mode 100644 index 00000000..179d8c9b --- /dev/null +++ b/src/renderer/components/team/ClaudeLogsFilterPopover.tsx @@ -0,0 +1,179 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { Button } from '@renderer/components/ui/button'; +import { Checkbox } from '@renderer/components/ui/checkbox'; +import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { Filter } from 'lucide-react'; + +export type ClaudeLogStream = 'stdout' | 'stderr'; +export type ClaudeLogKind = 'output' | 'thinking' | 'tool'; + +export interface ClaudeLogsFilterState { + streams: Set; + kinds: Set; +} + +export const DEFAULT_CLAUDE_LOGS_FILTER: ClaudeLogsFilterState = { + streams: new Set(['stdout', 'stderr']), + kinds: new Set(['output', 'thinking', 'tool']), +}; + +function setEquals(a: Set, b: Set): boolean { + if (a.size !== b.size) return false; + for (const v of a) if (!b.has(v)) return false; + return true; +} + +function getActiveCount(filter: ClaudeLogsFilterState): number { + let count = 0; + if (!setEquals(filter.streams, DEFAULT_CLAUDE_LOGS_FILTER.streams)) count += 1; + if (!setEquals(filter.kinds, DEFAULT_CLAUDE_LOGS_FILTER.kinds)) count += 1; + return count; +} + +interface ClaudeLogsFilterPopoverProps { + filter: ClaudeLogsFilterState; + open: boolean; + onOpenChange: (open: boolean) => void; + onApply: (filter: ClaudeLogsFilterState) => void; +} + +export const ClaudeLogsFilterPopover = ({ + filter, + open, + onOpenChange, + onApply, +}: ClaudeLogsFilterPopoverProps): React.JSX.Element => { + const [draft, setDraft] = useState(() => ({ + streams: new Set(filter.streams), + kinds: new Set(filter.kinds), + })); + + useEffect(() => { + if (!open) return; + const next = { streams: new Set(filter.streams), kinds: new Set(filter.kinds) }; + queueMicrotask(() => setDraft(next)); + }, [open, filter.streams, filter.kinds]); + + const activeCount = useMemo(() => getActiveCount(filter), [filter]); + const draftCount = useMemo(() => getActiveCount(draft), [draft]); + + const toggleStream = (stream: ClaudeLogStream): void => { + setDraft((prev) => { + const next = new Set(prev.streams); + if (next.has(stream)) next.delete(stream); + else next.add(stream); + // Prevent empty selection (keep at least one) + if (next.size === 0) { + next.add(stream); + } + return { ...prev, streams: next }; + }); + }; + + const toggleKind = (kind: ClaudeLogKind): void => { + setDraft((prev) => { + const next = new Set(prev.kinds); + if (next.has(kind)) next.delete(kind); + else next.add(kind); + // Prevent empty selection (keep at least one) + if (next.size === 0) { + next.add(kind); + } + return { ...prev, kinds: next }; + }); + }; + + const handleSave = (): void => { + onApply(draft); + onOpenChange(false); + }; + + const handleReset = (): void => { + const empty = { + streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams), + kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds), + }; + setDraft(empty); + onApply(empty); + }; + + return ( + + + + + + + + Filter logs + + +
+

+ Stream +

+
+ + +
+
+ +
+

+ Content +

+
+ + + +
+
+ +
+ + +
+
+
+ ); +}; + diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx index 9c110816..5cae2663 100644 --- a/src/renderer/components/team/ClaudeLogsSection.tsx +++ b/src/renderer/components/team/ClaudeLogsSection.tsx @@ -3,12 +3,17 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { Button } from '@renderer/components/ui/button'; import { cn } from '@renderer/lib/utils'; -import { Terminal } from 'lucide-react'; +import { Search, Terminal, X } from 'lucide-react'; import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { CliLogsRichView } from './CliLogsRichView'; +import { + ClaudeLogsFilterPopover, + DEFAULT_CLAUDE_LOGS_FILTER, +} from './ClaudeLogsFilterPopover'; import type { TeamClaudeLogsResponse } from '@shared/types'; +import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover'; const PAGE_SIZE = 100; const POLL_MS = 2000; @@ -65,22 +70,178 @@ function normalizeToStreamJsonText(linesNewestFirst: string[]): string { return out.join('\n'); } +type AssistantContentBlock = + | { type: 'text'; text?: string } + | { type: 'thinking'; thinking?: string } + | { type: 'tool_use'; id?: string; name?: string; input?: Record } + | { type: string; [key: string]: unknown }; + +function filterStreamJsonText( + linesNewestFirst: string[], + queryRaw: string, + filter: ClaudeLogsFilterState +): string { + const q = queryRaw.trim().toLowerCase(); + const chronological = normalizeToStreamJsonText(linesNewestFirst).split('\n'); + + let currentStream: 'stdout' | 'stderr' | null = null; + let lastEmittedStream: 'stdout' | 'stderr' | null = null; + const out: string[] = []; + + const emitMarker = (): void => { + if (!currentStream) return; + if (lastEmittedStream === currentStream) return; + out.push(currentStream === 'stdout' ? '[stdout]' : '[stderr]'); + lastEmittedStream = currentStream; + }; + + const extractBlocks = (parsed: Record): AssistantContentBlock[] | null => { + if (parsed.type !== 'assistant') return null; + if (Array.isArray(parsed.content)) { + return parsed.content as AssistantContentBlock[]; + } + const msg = parsed.message; + if (msg && typeof msg === 'object') { + const inner = msg as Record; + if (Array.isArray(inner.content)) return inner.content as AssistantContentBlock[]; + } + return null; + }; + + const writeBlocks = (parsed: Record, blocks: AssistantContentBlock[]): Record => { + if (Array.isArray(parsed.content)) { + return { ...parsed, content: blocks }; + } + const msg = parsed.message; + if (msg && typeof msg === 'object') { + return { ...parsed, message: { ...(msg as Record), content: blocks } }; + } + return parsed; + }; + + for (const rawLine of chronological) { + const line = rawLine.trimEnd(); + if (!line) continue; + + if (line === '[stdout]' || line === '[stderr]') { + currentStream = line === '[stdout]' ? 'stdout' : 'stderr'; + continue; + } + + if (currentStream && !filter.streams.has(currentStream)) { + continue; + } + + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + // Non-JSON lines are ignored to keep view consistent with CliLogsRichView. + continue; + } + + if (!parsed || typeof parsed !== 'object') continue; + const obj = parsed as Record; + + const blocks = extractBlocks(obj); + if (!blocks) { + // Keep only assistant messages for now (CliLogsRichView renders these richly). + continue; + } + + const filteredBlocks = blocks.filter((b) => { + if (!b || typeof b !== 'object') return false; + if (b.type === 'text') return filter.kinds.has('output'); + if (b.type === 'thinking') return filter.kinds.has('thinking'); + if (b.type === 'tool_use') return filter.kinds.has('tool'); + // Unknown block types: keep (they're rare, and dropping can hide content) + return true; + }); + if (filteredBlocks.length === 0) continue; + + const searchTextParts: string[] = []; + for (const b of filteredBlocks) { + if (b.type === 'text' && typeof b.text === 'string') searchTextParts.push(b.text); + if (b.type === 'thinking' && typeof b.thinking === 'string') searchTextParts.push(b.thinking); + if (b.type === 'tool_use') { + if (typeof b.name === 'string') searchTextParts.push(b.name); + if (b.input && typeof b.input === 'object') { + try { + searchTextParts.push(JSON.stringify(b.input)); + } catch { + // ignore + } + } + } + } + const haystack = searchTextParts.join('\n').toLowerCase(); + if (q && !haystack.includes(q)) { + continue; + } + + emitMarker(); + const nextObj = writeBlocks(obj, filteredBlocks); + out.push(JSON.stringify(nextObj)); + } + + return out.join('\n'); +} + export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.JSX.Element => { const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const [data, setData] = useState({ lines: [], total: 0, hasMore: false }); + const [pending, setPending] = useState(null); + const [pendingNewCount, setPendingNewCount] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const inFlightRef = useRef(false); + const atTopRef = useRef(true); + const latestRef = useRef(null); + const logContainerRef = useRef(null); + const committedRef = useRef({ lines: [], total: 0, hasMore: false }); + const pendingCountRef = useRef(0); + const [searchQuery, setSearchQuery] = useState(''); + const [filter, setFilter] = useState(() => ({ + streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams), + kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds), + })); + const [filterOpen, setFilterOpen] = useState(false); useEffect(() => { setVisibleCount(PAGE_SIZE); setData({ lines: [], total: 0, hasMore: false }); + setPending(null); + setPendingNewCount(0); + latestRef.current = null; + atTopRef.current = true; setError(null); + setSearchQuery(''); + setFilter({ + streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams), + kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds), + }); }, [teamName]); + useEffect(() => { + committedRef.current = data; + }, [data]); + + useEffect(() => { + pendingCountRef.current = pendingNewCount; + }, [pendingNewCount]); + useEffect(() => { let cancelled = false; + const computeNewCount = (committed: TeamClaudeLogsResponse, latest: TeamClaudeLogsResponse): number => { + if (committed.lines.length === 0) return latest.lines.length; + const marker = committed.lines[0]; + const idx = latest.lines.indexOf(marker); + if (idx >= 0) return idx; + const diff = (latest.total ?? latest.lines.length) - (committed.total ?? committed.lines.length); + return Math.max(0, diff); + }; + const fetchLogs = async (): Promise => { if (inFlightRef.current) return; inFlightRef.current = true; @@ -88,7 +249,16 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J setLoading(true); const next = await api.teams.getClaudeLogs(teamName, { offset: 0, limit: visibleCount }); if (cancelled) return; - setData(next); + latestRef.current = next; + if (atTopRef.current) { + setData(next); + setPending(null); + setPendingNewCount(0); + } else { + setPending(next); + const base = computeNewCount(committedRef.current, next); + setPendingNewCount((prev) => Math.max(prev, base)); + } setError(null); } catch (e) { if (cancelled) return; @@ -118,6 +288,32 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J ) : null; + const filteredText = useMemo(() => { + if (data.lines.length === 0) return ''; + const isDefault = + filter.streams.size === DEFAULT_CLAUDE_LOGS_FILTER.streams.size && + filter.kinds.size === DEFAULT_CLAUDE_LOGS_FILTER.kinds.size && + [...DEFAULT_CLAUDE_LOGS_FILTER.streams].every((s) => filter.streams.has(s)) && + [...DEFAULT_CLAUDE_LOGS_FILTER.kinds].every((k) => filter.kinds.has(k)); + + if (!searchQuery.trim() && isDefault) { + return normalizeToStreamJsonText(data.lines); + } + return filterStreamJsonText(data.lines, searchQuery, filter); + }, [data.lines, searchQuery, filter]); + + const applyPending = (): void => { + const latest = latestRef.current ?? pending; + if (!latest) return; + setData(latest); + setPending(null); + setPendingNewCount(0); + // Jump to newest + if (logContainerRef.current) { + logContainerRef.current.scrollTop = 0; + } + }; + return ( - {showMoreVisible && ( - - )} +
+
+ + setSearchQuery(e.target.value)} + className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none" + /> + {searchQuery && ( + + )} +
+ + {pendingNewCount > 0 && ( + + )} + {showMoreVisible && ( + + )} +
{error ?

{error}

: null} - {!error && data.lines.length > 0 ? ( + {!error && filteredText.trim().length > 0 ? ( { + logContainerRef.current = el; + }} + onScroll={({ scrollTop }) => { + const atTop = scrollTop <= 8; + atTopRef.current = atTop; + if (atTop && pendingCountRef.current > 0) { + applyPending(); + } + }} /> ) : null} {!error && data.lines.length === 0 ? ( @@ -171,6 +416,11 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J {loading ? 'Loading…' : 'No logs captured.'}

) : null} + {!error && data.lines.length > 0 && filteredText.trim().length === 0 ? ( +

+ No matching logs. +

+ ) : null}
); diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx index dfc82fc0..bb04ff47 100644 --- a/src/renderer/components/team/CliLogsRichView.tsx +++ b/src/renderer/components/team/CliLogsRichView.tsx @@ -19,6 +19,8 @@ import type { StreamJsonGroup } from '@renderer/utils/streamJsonParser'; interface CliLogsRichViewProps { cliLogsTail: string; order?: 'oldest-first' | 'newest-first'; + onScroll?: (params: { scrollTop: number; scrollHeight: number; clientHeight: number }) => void; + containerRefCallback?: (el: HTMLDivElement | null) => void; className?: string; } @@ -130,9 +132,11 @@ const StreamGroup = ({ export const CliLogsRichView = ({ cliLogsTail, order = 'oldest-first', + onScroll, + containerRefCallback, className, }: CliLogsRichViewProps): React.JSX.Element => { - const scrollRef = useRef(null); + const scrollRef = useRef(null); // Tracks groups manually collapsed by user (default: all auto-expanded) const [collapsedGroupIds, setCollapsedGroupIds] = useState>(new Set()); const [expandedItemIds, setExpandedItemIds] = useState>(new Set()); @@ -190,11 +194,18 @@ export const CliLogsRichView = ({ const hasContent = cliLogsTail.trim().length > 0; return (
{ + scrollRef.current = el; + containerRefCallback?.(el); + }} className={cn( 'max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)]', className )} + onScroll={(e) => { + const el = e.currentTarget; + onScroll?.({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }); + }} > {hasContent ? (
@@ -212,7 +223,17 @@ export const CliLogsRichView = ({
   const visibleGroups = order === 'newest-first' ? [...groups].reverse() : groups;
 
   return (
-    
+
{ + scrollRef.current = el; + containerRefCallback?.(el); + }} + className={cn('max-h-[400px] space-y-1.5 overflow-y-auto', className)} + onScroll={(e) => { + const el = e.currentTarget; + onScroll?.({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }); + }} + > {visibleGroups.map((group) => group.items.length === 1 ? ( // Single item — render flat without collapsible group wrapper diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index e051da90..251538e0 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -27,6 +27,7 @@ import { nameColorSet } from '@renderer/utils/projectColor'; import { resolveProjectIdByPath } from '@renderer/utils/projectLookup'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; +import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { AlertTriangle, Bell, @@ -304,6 +305,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const [messagesFilter, setMessagesFilter] = useState({ from: new Set(), to: new Set(), + showNoise: false, }); const [messagesFilterOpen, setMessagesFilterOpen] = useState(false); @@ -444,6 +446,24 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele [visibleContextTokens, lastAiGroupTotalTokens] ); + const activeTabId = useStore((s) => s.activeTabId); + const isThisTabActive = tabId ? activeTabId === tabId : false; + + // Keep lead-session context fresh in the background while the team tab is active. + // This keeps the button value current even when the panel is closed. + useEffect(() => { + if (!isThisTabActive) return; + if (!tabId || !projectId || !leadSessionId) return; + if (!data?.isAlive) return; + + const tick = (): void => { + void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true }); + }; + tick(); + const id = window.setInterval(tick, 30_000); + return () => window.clearInterval(id); + }, [isThisTabActive, tabId, projectId, leadSessionId, data?.isAlive, fetchSessionDetail]); + useEffect(() => { if (!projectId) return; @@ -558,6 +578,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele return ts >= timeWindow.start && ts < timeWindow.end; }); } + if (!messagesFilter.showNoise) { + list = list.filter((m) => !isInboxNoiseMessage(typeof m.text === 'string' ? m.text : '')); + } if (messagesFilter.from.size > 0) { list = list.filter((m) => m.from?.trim() && messagesFilter.from.has(m.from.trim())); } @@ -874,8 +897,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele onClick={() => { const next = !isContextPanelVisible; setContextPanelVisible(next); - if (next && tabId && projectId && leadSessionId && !leadSessionLoaded) { - void fetchSessionDetail(projectId, leadSessionId, tabId); + if (tabId && projectId && leadSessionId) { + void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true }); } }} onMouseEnter={() => setIsContextButtonHovered(true)} @@ -899,11 +922,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele : leadSessionId } > - {visibleContextPercentLabel - ? visibleContextPercentLabel - : typeof leadContextPercent === 'number' - ? `${Math.round(leadContextPercent)}%` - : 'Context'} + {visibleContextPercentLabel ?? 'Context'}
)} diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 4916a5ff..45d8ad17 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -171,12 +171,14 @@ export const SendMessageDialog = ({ const attachmentsBlocked = attachments.length > 0 && !supportsAttachments; const trimmedText = textDraft.value.trim(); - const remaining = MAX_MESSAGE_LENGTH - trimmedText.length; + const serialized = serializeChipsWithText(trimmedText, chipDraft.chips); + const finalText = quote ? buildReplyBlock(quote.from, quote.text, serialized) : serialized; + const remaining = MAX_MESSAGE_LENGTH - finalText.length; const canSend = member.trim().length > 0 && - trimmedText.length > 0 && - trimmedText.length <= MAX_MESSAGE_LENGTH && + finalText.length > 0 && + finalText.length <= MAX_MESSAGE_LENGTH && summary.trim().length > 0 && !sending && !attachmentsBlocked; @@ -191,8 +193,6 @@ export const SendMessageDialog = ({ const handleSubmit = (): void => { if (!canSend) return; - const serialized = serializeChipsWithText(textDraft.value.trim(), chipDraft.chips); - const finalText = quote ? buildReplyBlock(quote.from, quote.text, serialized) : serialized; onSend( member.trim(), finalText, diff --git a/src/renderer/components/team/dialogs/TaskAttachments.tsx b/src/renderer/components/team/dialogs/TaskAttachments.tsx index 5f608fbd..aa823502 100644 --- a/src/renderer/components/team/dialogs/TaskAttachments.tsx +++ b/src/renderer/components/team/dialogs/TaskAttachments.tsx @@ -32,6 +32,7 @@ export const TaskAttachments = ({ const [deletingId, setDeletingId] = useState(null); const [error, setError] = useState(null); const [lightboxIndex, setLightboxIndex] = useState(null); + const [lightboxSlides, setLightboxSlides] = useState<{ src: string; alt: string }[]>([]); const [thumbCache, setThumbCache] = useState>(new Map()); const fileInputRef = useRef(null); @@ -130,9 +131,19 @@ export const TaskAttachments = ({ return; } const idx = imageAttachments.findIndex((a) => a.id === att.id); - if (idx >= 0) setLightboxIndex(idx); + if (idx >= 0) { + const snapshot = imageAttachments.map((a) => { + const dataUrl = thumbCache.get(a.id); + return { + src: dataUrl ?? `data:image/svg+xml,`, + alt: a.filename, + }; + }); + setLightboxSlides(snapshot); + setLightboxIndex(idx); + } }, - [imageAttachments, handleDownload] + [imageAttachments, thumbCache, handleDownload] ); // Handle paste events for quick image attachment @@ -209,13 +220,11 @@ export const TaskAttachments = ({ {lightboxIndex !== null && ( setLightboxIndex(null)} - slides={imageAttachments - .map((att) => { - const dataUrl = thumbCache.get(att.id); - return dataUrl ? { src: dataUrl, alt: att.filename } : null; - }) - .filter(Boolean) as { src: string; alt: string }[]} + onClose={() => { + setLightboxIndex(null); + setLightboxSlides([]); + }} + slides={lightboxSlides} index={lightboxIndex} /> )} diff --git a/src/renderer/components/team/messages/MessagesFilterPopover.tsx b/src/renderer/components/team/messages/MessagesFilterPopover.tsx index 8f0a72aa..25117732 100644 --- a/src/renderer/components/team/messages/MessagesFilterPopover.tsx +++ b/src/renderer/components/team/messages/MessagesFilterPopover.tsx @@ -14,6 +14,8 @@ import type { InboxMessage } from '@shared/types'; export interface MessagesFilterState { from: Set; to: Set; + /** When true, include internal coordination noise (idle/shutdown/etc.) */ + showNoise: boolean; } interface MessagesFilterPopoverProps { @@ -47,18 +49,23 @@ export const MessagesFilterPopover = ({ onOpenChange, onApply, }: MessagesFilterPopoverProps): React.JSX.Element => { - const [draft, setDraft] = useState({ from: new Set(), to: new Set() }); + const [draft, setDraft] = useState({ + from: new Set(), + to: new Set(), + showNoise: false, + }); useEffect(() => { if (open) { const next = { from: new Set(filter.from), to: new Set(filter.to), + showNoise: !!filter.showNoise, }; const schedule = (): void => setDraft(next); queueMicrotask(schedule); } - }, [open, filter.from, filter.to]); + }, [open, filter.from, filter.to, filter.showNoise]); const members = useStore((s) => s.selectedTeamData?.members ?? []); const colorMap = useMemo(() => buildMemberColorMap(members), [members]); @@ -93,7 +100,7 @@ export const MessagesFilterPopover = ({ }; const handleReset = (): void => { - const empty = { from: new Set(), to: new Set() }; + const empty = { from: new Set(), to: new Set(), showNoise: false }; setDraft(empty); onApply(empty); }; @@ -164,12 +171,21 @@ export const MessagesFilterPopover = ({ )}
+
+ +