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:
parent
98593b495d
commit
70fdc2537a
15 changed files with 697 additions and 124 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
179
src/renderer/components/team/ClaudeLogsFilterPopover.tsx
Normal file
179
src/renderer/components/team/ClaudeLogsFilterPopover.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
38
src/shared/utils/inboxNoise.ts
Normal file
38
src/shared/utils/inboxNoise.ts
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue