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.
This commit is contained in:
iliya 2026-03-05 17:27:09 +02:00
parent 98593b495d
commit 70fdc2537a
15 changed files with 697 additions and 124 deletions

View file

@ -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<string> {
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<string, unknown> | 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<string, unknown>;
}
} 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).

View file

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

View file

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

View file

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

View file

@ -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<ClaudeLogStream>;
kinds: Set<ClaudeLogKind>;
}
export const DEFAULT_CLAUDE_LOGS_FILTER: ClaudeLogsFilterState = {
streams: new Set<ClaudeLogStream>(['stdout', 'stderr']),
kinds: new Set<ClaudeLogKind>(['output', 'thinking', 'tool']),
};
function setEquals<T>(a: Set<T>, b: Set<T>): 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<ClaudeLogsFilterState>(() => ({
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 (
<Popover open={open} onOpenChange={onOpenChange}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
aria-label="Filter Claude logs"
>
<Filter size={14} />
{activeCount > 0 && (
<span className="absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-medium text-white">
{activeCount}
</span>
)}
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Filter logs</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-72 p-0">
<div className="border-b border-[var(--color-border)] p-3">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
Stream
</p>
<div className="space-y-1">
<label className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]">
<Checkbox checked={draft.streams.has('stdout')} onCheckedChange={() => toggleStream('stdout')} />
stdout
</label>
<label className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]">
<Checkbox checked={draft.streams.has('stderr')} onCheckedChange={() => toggleStream('stderr')} />
stderr
</label>
</div>
</div>
<div className="border-b border-[var(--color-border)] p-3">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
Content
</p>
<div className="space-y-1">
<label className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]">
<Checkbox checked={draft.kinds.has('output')} onCheckedChange={() => toggleKind('output')} />
Output
</label>
<label className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]">
<Checkbox checked={draft.kinds.has('thinking')} onCheckedChange={() => toggleKind('thinking')} />
Thinking
</label>
<label className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]">
<Checkbox checked={draft.kinds.has('tool')} onCheckedChange={() => toggleKind('tool')} />
Tool calls
</label>
</div>
</div>
<div className="flex justify-between gap-2 p-2">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
disabled={draftCount === 0}
onClick={handleReset}
>
Reset
</Button>
<Button size="sm" className="h-7 px-3 text-[11px]" onClick={handleSave}>
Save
</Button>
</div>
</PopoverContent>
</Popover>
);
};

View file

@ -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<string, unknown> }
| { 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<string, unknown>): 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<string, unknown>;
if (Array.isArray(inner.content)) return inner.content as AssistantContentBlock[];
}
return null;
};
const writeBlocks = (parsed: Record<string, unknown>, blocks: AssistantContentBlock[]): Record<string, unknown> => {
if (Array.isArray(parsed.content)) {
return { ...parsed, content: blocks };
}
const msg = parsed.message;
if (msg && typeof msg === 'object') {
return { ...parsed, message: { ...(msg as Record<string, unknown>), 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<string, unknown>;
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<TeamClaudeLogsResponse>({ lines: [], total: 0, hasMore: false });
const [pending, setPending] = useState<TeamClaudeLogsResponse | null>(null);
const [pendingNewCount, setPendingNewCount] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inFlightRef = useRef(false);
const atTopRef = useRef(true);
const latestRef = useRef<TeamClaudeLogsResponse | null>(null);
const logContainerRef = useRef<HTMLDivElement | null>(null);
const committedRef = useRef<TeamClaudeLogsResponse>({ lines: [], total: 0, hasMore: false });
const pendingCountRef = useRef(0);
const [searchQuery, setSearchQuery] = useState('');
const [filter, setFilter] = useState<ClaudeLogsFilterState>(() => ({
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<void> => {
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
</span>
) : 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 (
<CollapsibleTeamSection
sectionId="claude-logs"
@ -139,16 +335,55 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
'No logs yet.'
)}
</span>
{showMoreVisible && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
>
Show more
</Button>
)}
<div className="flex items-center gap-2">
<div className="flex w-48 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
<Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
<input
type="text"
placeholder="Search logs..."
value={searchQuery}
onChange={(e) => 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 && (
<button
type="button"
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={() => setSearchQuery('')}
aria-label="Clear search"
>
<X size={14} />
</button>
)}
</div>
<ClaudeLogsFilterPopover
filter={filter}
open={filterOpen}
onOpenChange={setFilterOpen}
onApply={setFilter}
/>
{pendingNewCount > 0 && (
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-xs"
onClick={applyPending}
title="Show newest logs"
>
+{pendingNewCount} new
</Button>
)}
{showMoreVisible && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
>
Show more
</Button>
)}
</div>
</div>
<div
@ -158,12 +393,22 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
)}
>
{error ? <p className="p-2 text-xs text-red-300">{error}</p> : null}
{!error && data.lines.length > 0 ? (
{!error && filteredText.trim().length > 0 ? (
<CliLogsRichView
// Parser expects chronological order; UI shows newest-first.
cliLogsTail={normalizeToStreamJsonText(data.lines)}
cliLogsTail={filteredText}
order="newest-first"
className="max-h-[320px] p-2"
containerRefCallback={(el) => {
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.'}
</p>
) : null}
{!error && data.lines.length > 0 && filteredText.trim().length === 0 ? (
<p className="p-2 text-xs text-[var(--color-text-muted)]">
No matching logs.
</p>
) : null}
</div>
</CollapsibleTeamSection>
);

View file

@ -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<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
// Tracks groups manually collapsed by user (default: all auto-expanded)
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(new Set());
const [expandedItemIds, setExpandedItemIds] = useState<Set<string>>(new Set());
@ -190,11 +194,18 @@ export const CliLogsRichView = ({
const hasContent = cliLogsTail.trim().length > 0;
return (
<div
ref={scrollRef}
ref={(el) => {
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 ? (
<pre className="p-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]">
@ -212,7 +223,17 @@ export const CliLogsRichView = ({
const visibleGroups = order === 'newest-first' ? [...groups].reverse() : groups;
return (
<div ref={scrollRef} className={cn('max-h-[400px] space-y-1.5 overflow-y-auto', className)}>
<div
ref={(el) => {
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

View file

@ -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<MessagesFilterState>({
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'}
</button>
</div>
)}

View file

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

View file

@ -32,6 +32,7 @@ export const TaskAttachments = ({
const [deletingId, setDeletingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [lightboxSlides, setLightboxSlides] = useState<{ src: string; alt: string }[]>([]);
const [thumbCache, setThumbCache] = useState<Map<string, string>>(new Map());
const fileInputRef = useRef<HTMLInputElement>(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,<svg xmlns="http://www.w3.org/2000/svg"/>`,
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 && (
<ImageLightbox
open
onClose={() => 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}
/>
)}

View file

@ -14,6 +14,8 @@ import type { InboxMessage } from '@shared/types';
export interface MessagesFilterState {
from: Set<string>;
to: Set<string>;
/** 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<MessagesFilterState>({ from: new Set(), to: new Set() });
const [draft, setDraft] = useState<MessagesFilterState>({
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<string>(), to: new Set<string>() };
const empty = { from: new Set<string>(), to: new Set<string>(), showNoise: false };
setDraft(empty);
onApply(empty);
};
@ -164,12 +171,21 @@ export const MessagesFilterPopover = ({
)}
</div>
</div>
<div className="border-b border-[var(--color-border)] p-3">
<label className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]">
<Checkbox
checked={draft.showNoise}
onCheckedChange={() => setDraft((prev) => ({ ...prev, showNoise: !prev.showNoise }))}
/>
<span>Show status updates (idle/shutdown)</span>
</label>
</div>
<div className="flex justify-between gap-2 p-2">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
disabled={draftCount === 0}
disabled={draftCount === 0 && !draft.showNoise}
onClick={handleReset}
>
Reset

View file

@ -120,7 +120,12 @@ export interface SessionDetailSlice {
tabSessionData: Record<string, TabSessionData>;
// Actions
fetchSessionDetail: (projectId: string, sessionId: string, tabId?: string) => Promise<void>;
fetchSessionDetail: (
projectId: string,
sessionId: string,
tabId?: string,
options?: { silent?: boolean }
) => Promise<void>;
/** Refresh session without loading states or UI resets - for real-time updates */
refreshSessionInPlace: (projectId: string, sessionId: string) => Promise<void>;
setVisibleAIGroup: (aiGroupId: string | null) => void;
@ -162,16 +167,23 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
tabSessionData: {},
// Fetch full session detail with chunks and subagents
fetchSessionDetail: async (projectId: string, sessionId: string, tabId?: string) => {
fetchSessionDetail: async (
projectId: string,
sessionId: string,
tabId?: string,
options?: { silent?: boolean }
) => {
const requestGeneration = incrementTabGeneration(tabId);
set({
sessionDetailLoading: true,
sessionDetailError: null,
conversationLoading: true,
});
if (!options?.silent) {
set({
sessionDetailLoading: true,
sessionDetailError: null,
conversationLoading: true,
});
}
// Also set per-tab loading state
if (tabId) {
if (tabId && !options?.silent) {
const prev = get().tabSessionData;
set({
tabSessionData: {
@ -461,10 +473,12 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
sessionPhaseInfo: phaseInfo,
});
} else {
set({
sessionDetailLoading: false,
conversationLoading: false,
});
if (!options?.silent) {
set({
sessionDetailLoading: false,
conversationLoading: false,
});
}
}
} catch (error) {
logger.error('fetchSessionDetail error:', error);
@ -472,14 +486,16 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
return;
}
const errorMsg = error instanceof Error ? error.message : 'Failed to fetch session detail';
set({
sessionDetailError: errorMsg,
sessionDetailLoading: false,
conversationLoading: false,
});
if (!options?.silent) {
set({
sessionDetailError: errorMsg,
sessionDetailLoading: false,
conversationLoading: false,
});
}
// Store per-tab error state
if (tabId) {
if (tabId && !options?.silent) {
const prev = get().tabSessionData;
set({
tabSessionData: {

View file

@ -12,8 +12,8 @@ export function computePercentOfTotal(
visibleTokens: number,
totalSessionTokens: number | undefined
): number | null {
if (!Number.isFinite(visibleTokens) || visibleTokens <= 0) return 0;
if (totalSessionTokens === undefined || totalSessionTokens <= 0) return null;
if (!Number.isFinite(visibleTokens) || visibleTokens <= 0) return 0;
return Math.min((visibleTokens / totalSessionTokens) * 100, 100);
}

View file

@ -174,13 +174,14 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
let currentItems: AIGroupDisplayItem[] = [];
let currentTimestamp: Date | null = null;
let currentGroupId: string | null = null;
let groupCounter = 0;
// Track how many times each messageId has been seen to disambiguate duplicates
const msgIdOccurrences = new Map<string, number>();
// Stable timestamp for the entire parse (deterministic across re-renders)
const parseTimestamp = new Date();
const flushGroup = (): void => {
if (currentItems.length > 0 && currentTimestamp) {
const id = currentGroupId ?? `stream-group-${groupCounter++}`;
const id = currentGroupId ?? `stream-group-fallback-${groups.length}`;
groups.push({
id,
items: currentItems,
@ -221,7 +222,15 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
if (!currentTimestamp) currentTimestamp = parseTimestamp;
if (!currentGroupId) {
const msgId = extractAssistantMessageId(parsed);
currentGroupId = msgId ? `stream-group-${msgId}` : `stream-group-L${lineIndex}`;
if (msgId) {
const occurrence = msgIdOccurrences.get(msgId) ?? 0;
msgIdOccurrences.set(msgId, occurrence + 1);
currentGroupId = occurrence === 0
? `stream-group-${msgId}`
: `stream-group-${msgId}-${occurrence}`;
} else {
currentGroupId = `stream-group-L${lineIndex}`;
}
}
const items = contentBlocksToDisplayItems(blocks, parseTimestamp, lineIndex);

View file

@ -0,0 +1,38 @@
/**
* Inbox "noise" messages are structured JSON objects that represent internal coordination
* signals (idle/shutdown/etc.). They should not trigger user-facing notifications or
* automatic lead relays.
*/
export const INBOX_NOISE_TYPES = [
'idle_notification',
'shutdown_approved',
'teammate_terminated',
'shutdown_request',
] as const;
const INBOX_NOISE_SET = new Set<string>(INBOX_NOISE_TYPES);
export function parseInboxJson(text: string): Record<string, unknown> | 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<string, unknown>;
}
} catch {
// not JSON
}
return null;
}
export function getInboxJsonType(text: string): string | null {
const parsed = parseInboxJson(text);
if (!parsed) return null;
return typeof parsed.type === 'string' ? parsed.type : null;
}
export function isInboxNoiseMessage(text: string): boolean {
const type = getInboxJsonType(text);
return !!type && INBOX_NOISE_SET.has(type);
}