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,
|
getTrafficLightPositionForZoom,
|
||||||
WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL,
|
WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL,
|
||||||
} from '@shared/constants';
|
} from '@shared/constants';
|
||||||
|
import { isInboxNoiseMessage, parseInboxJson } from '@shared/utils/inboxNoise';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { app, BrowserWindow } from 'electron';
|
import { app, BrowserWindow } from 'electron';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
|
|
@ -132,42 +133,6 @@ async function resolveTeamDisplayName(teamName: string): Promise<string> {
|
||||||
return resolved;
|
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.
|
* Extracts human-readable summary and body from an inbox message.
|
||||||
* Handles both plain text and serialized JSON ({"type":"message","content":"...","summary":"..."}).
|
* 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 ---
|
// --- Inbox change events: relay to lead + native OS notifications ---
|
||||||
if (row.type === 'inbox') {
|
if (row.type === 'inbox') {
|
||||||
// Auto-relay direct messages to live team lead process (no UI dependency).
|
// Auto-relay ONLY lead-inbox changes into the live lead process.
|
||||||
if (teamProvisioningService.isTeamAlive(teamName)) {
|
// (Relaying on *any* inbox change causes the lead to process irrelevant status noise.)
|
||||||
void teamProvisioningService
|
if (teamProvisioningService.isTeamAlive(teamName) && detail.startsWith('inboxes/')) {
|
||||||
.relayLeadInboxMessages(teamName)
|
const match = /^inboxes\/(.+)\.json$/.exec(detail);
|
||||||
.catch((e: unknown) => logger.warn(`[FileWatcher] relay failed for ${teamName}: ${e}`));
|
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).
|
// Show native OS notification for new inbox messages (debounced per inbox).
|
||||||
|
|
|
||||||
|
|
@ -392,12 +392,6 @@ async function handleGetData(
|
||||||
const provisioning = getTeamProvisioningService();
|
const provisioning = getTeamProvisioningService();
|
||||||
const isAlive = provisioning.isTeamAlive(tn);
|
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 displayName = data.config.name || tn;
|
||||||
const projectPath = data.config.projectPath;
|
const projectPath = data.config.projectPath;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,29 @@ function die(message, code = 1) {
|
||||||
process.exit(code);
|
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) {
|
function parseArgs(argv) {
|
||||||
const out = { _: [], flags: {} };
|
const out = { _: [], flags: {} };
|
||||||
for (let i = 0; i < argv.length; i++) {
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
|
@ -128,7 +151,7 @@ function getTeamName(flags) {
|
||||||
(typeof flags.team === 'string' && flags.team.trim()) ||
|
(typeof flags.team === 'string' && flags.team.trim()) ||
|
||||||
(typeof flags['teamName'] === 'string' && flags['teamName'].trim()) ||
|
(typeof flags['teamName'] === 'string' && flags['teamName'].trim()) ||
|
||||||
'';
|
'';
|
||||||
if (explicit) return explicit;
|
if (explicit) return assertSafePathSegment('team', explicit);
|
||||||
const inferred = inferTeamNameFromScriptPath();
|
const inferred = inferTeamNameFromScriptPath();
|
||||||
if (inferred) return inferred;
|
if (inferred) return inferred;
|
||||||
die('Missing --team (and could not infer team name from script path)');
|
die('Missing --team (and could not infer team name from script path)');
|
||||||
|
|
@ -258,7 +281,8 @@ function detectMimeTypeFromPathAndHeader(filePath, filename) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTaskAttachmentsDir(paths, taskId) {
|
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) {
|
function getStoredAttachmentPath(paths, taskId, attachmentId, filename) {
|
||||||
|
|
@ -421,8 +445,9 @@ function normalizeColumn(value) {
|
||||||
|
|
||||||
function getPaths(flags, teamName) {
|
function getPaths(flags, teamName) {
|
||||||
const claudeDir = getClaudeDir(flags);
|
const claudeDir = getClaudeDir(flags);
|
||||||
const teamDir = path.join(claudeDir, 'teams', teamName);
|
const safeTeam = assertSafePathSegment('team', teamName);
|
||||||
const tasksDir = path.join(claudeDir, 'tasks', teamName);
|
const teamDir = path.join(claudeDir, 'teams', safeTeam);
|
||||||
|
const tasksDir = path.join(claudeDir, 'tasks', safeTeam);
|
||||||
const kanbanPath = path.join(teamDir, 'kanban-state.json');
|
const kanbanPath = path.join(teamDir, 'kanban-state.json');
|
||||||
const processesPath = path.join(teamDir, 'processes.json');
|
const processesPath = path.join(teamDir, 'processes.json');
|
||||||
return { claudeDir, teamDir, tasksDir, kanbanPath, processesPath };
|
return { claudeDir, teamDir, tasksDir, kanbanPath, processesPath };
|
||||||
|
|
@ -438,7 +463,7 @@ function inferLeadName(paths) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function readTask(paths, taskId) {
|
function readTask(paths, taskId) {
|
||||||
const taskPath = path.join(paths.tasksDir, String(taskId) + '.json');
|
const taskPath = getTaskJsonPath(paths, taskId);
|
||||||
const task = readJson(taskPath, null);
|
const task = readJson(taskPath, null);
|
||||||
if (!task) die('Task not found: ' + String(taskId));
|
if (!task) die('Task not found: ' + String(taskId));
|
||||||
return { taskPath, task };
|
return { taskPath, task };
|
||||||
|
|
@ -607,13 +632,13 @@ function parseIdList(value) {
|
||||||
|
|
||||||
function taskExists(paths, taskId) {
|
function taskExists(paths, taskId) {
|
||||||
try {
|
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;
|
return true;
|
||||||
} catch (e) { return false; }
|
} catch (e) { return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function readTaskObject(paths, taskId) {
|
function readTaskObject(paths, taskId) {
|
||||||
var taskPath = path.join(paths.tasksDir, String(taskId) + '.json');
|
var taskPath = getTaskJsonPath(paths, taskId);
|
||||||
var t = readJson(taskPath, null);
|
var t = readJson(taskPath, null);
|
||||||
if (!t) die('Task not found: #' + taskId);
|
if (!t) die('Task not found: #' + taskId);
|
||||||
return { task: t, taskPath: taskPath };
|
return { task: t, taskPath: taskPath };
|
||||||
|
|
@ -628,7 +653,8 @@ function wouldCreateBlockCycle(paths, sourceId, targetId) {
|
||||||
if (visited[current]) continue;
|
if (visited[current]) continue;
|
||||||
visited[current] = true;
|
visited[current] = true;
|
||||||
try {
|
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)) {
|
if (t && Array.isArray(t.blockedBy)) {
|
||||||
for (var i = 0; i < t.blockedBy.length; i++) stack.push(String(t.blockedBy[i]));
|
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;
|
let taskPath;
|
||||||
while (true) {
|
while (true) {
|
||||||
nextId = getNextTaskId(paths);
|
nextId = getNextTaskId(paths);
|
||||||
taskPath = path.join(paths.tasksDir, String(nextId) + '.json');
|
taskPath = getTaskJsonPath(paths, nextId);
|
||||||
var createdAt = nowIso();
|
var createdAt = nowIso();
|
||||||
task = {
|
task = {
|
||||||
id: nextId,
|
id: nextId,
|
||||||
|
|
@ -825,7 +851,8 @@ function sendInboxMessage(paths, teamName, flags) {
|
||||||
const summary = typeof flags.summary === 'string' ? flags.summary : undefined;
|
const summary = typeof flags.summary === 'string' ? flags.summary : undefined;
|
||||||
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
|
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));
|
ensureDir(path.dirname(inboxPath));
|
||||||
|
|
||||||
const messageId = makeId();
|
const messageId = makeId();
|
||||||
|
|
@ -1026,7 +1053,8 @@ function taskBriefing(paths, teamName, flags) {
|
||||||
var allTasks = [];
|
var allTasks = [];
|
||||||
for (var i = 0; i < ids.length; i++) {
|
for (var i = 0; i < ids.length; i++) {
|
||||||
try {
|
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);
|
var t = readJson(taskPath, null);
|
||||||
if (t && !String(t.id).startsWith('_internal') && !(t.metadata && t.metadata._internal === true)) {
|
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 = ''; }
|
try { t._mtime = fs.statSync(taskPath).mtime.toISOString(); } catch (_e) { t._mtime = ''; }
|
||||||
|
|
@ -1272,7 +1300,7 @@ async function main() {
|
||||||
const tasks = [];
|
const tasks = [];
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
try {
|
try {
|
||||||
tasks.push(readJson(path.join(paths.tasksDir, String(id) + '.json'), null));
|
tasks.push(readJson(getTaskJsonPath(paths, id), null));
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
process.stdout.write(JSON.stringify(tasks.filter(Boolean), null, 2) + '\n');
|
process.stdout.write(JSON.stringify(tasks.filter(Boolean), null, 2) + '\n');
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
} from '@shared/constants/agentBlocks';
|
} from '@shared/constants/agentBlocks';
|
||||||
import { getMemberColor } from '@shared/constants/memberColors';
|
import { getMemberColor } from '@shared/constants/memberColors';
|
||||||
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||||
|
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
|
@ -2245,8 +2246,23 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
if (unread.length === 0) return 0;
|
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 MAX_RELAY = 10;
|
||||||
const batch = unread.slice(0, MAX_RELAY);
|
const batch = actionableUnread.slice(0, MAX_RELAY);
|
||||||
|
|
||||||
const message = [
|
const message = [
|
||||||
`You have new inbox messages addressed to you (team lead "${leadName}").`,
|
`You have new inbox messages addressed to you (team lead "${leadName}").`,
|
||||||
|
|
@ -3156,6 +3172,8 @@ export class TeamProvisioningService {
|
||||||
this.relayedLeadInboxMessageIds.delete(run.teamName);
|
this.relayedLeadInboxMessageIds.delete(run.teamName);
|
||||||
this.relayedLeadInboxFallbackKeys.delete(run.teamName);
|
this.relayedLeadInboxFallbackKeys.delete(run.teamName);
|
||||||
this.liveLeadProcessMessages.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 { api } from '@renderer/api';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { Terminal } from 'lucide-react';
|
import { Search, Terminal, X } from 'lucide-react';
|
||||||
|
|
||||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||||
import { CliLogsRichView } from './CliLogsRichView';
|
import { CliLogsRichView } from './CliLogsRichView';
|
||||||
|
import {
|
||||||
|
ClaudeLogsFilterPopover,
|
||||||
|
DEFAULT_CLAUDE_LOGS_FILTER,
|
||||||
|
} from './ClaudeLogsFilterPopover';
|
||||||
|
|
||||||
import type { TeamClaudeLogsResponse } from '@shared/types';
|
import type { TeamClaudeLogsResponse } from '@shared/types';
|
||||||
|
import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover';
|
||||||
|
|
||||||
const PAGE_SIZE = 100;
|
const PAGE_SIZE = 100;
|
||||||
const POLL_MS = 2000;
|
const POLL_MS = 2000;
|
||||||
|
|
@ -65,22 +70,178 @@ function normalizeToStreamJsonText(linesNewestFirst: string[]): string {
|
||||||
return out.join('\n');
|
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 => {
|
export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.JSX.Element => {
|
||||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||||
const [data, setData] = useState<TeamClaudeLogsResponse>({ lines: [], total: 0, hasMore: false });
|
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 [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const inFlightRef = useRef(false);
|
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(() => {
|
useEffect(() => {
|
||||||
setVisibleCount(PAGE_SIZE);
|
setVisibleCount(PAGE_SIZE);
|
||||||
setData({ lines: [], total: 0, hasMore: false });
|
setData({ lines: [], total: 0, hasMore: false });
|
||||||
|
setPending(null);
|
||||||
|
setPendingNewCount(0);
|
||||||
|
latestRef.current = null;
|
||||||
|
atTopRef.current = true;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setSearchQuery('');
|
||||||
|
setFilter({
|
||||||
|
streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams),
|
||||||
|
kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds),
|
||||||
|
});
|
||||||
}, [teamName]);
|
}, [teamName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
committedRef.current = data;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pendingCountRef.current = pendingNewCount;
|
||||||
|
}, [pendingNewCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
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> => {
|
const fetchLogs = async (): Promise<void> => {
|
||||||
if (inFlightRef.current) return;
|
if (inFlightRef.current) return;
|
||||||
inFlightRef.current = true;
|
inFlightRef.current = true;
|
||||||
|
|
@ -88,7 +249,16 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const next = await api.teams.getClaudeLogs(teamName, { offset: 0, limit: visibleCount });
|
const next = await api.teams.getClaudeLogs(teamName, { offset: 0, limit: visibleCount });
|
||||||
if (cancelled) return;
|
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);
|
setError(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
@ -118,6 +288,32 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
||||||
</span>
|
</span>
|
||||||
) : null;
|
) : 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 (
|
return (
|
||||||
<CollapsibleTeamSection
|
<CollapsibleTeamSection
|
||||||
sectionId="claude-logs"
|
sectionId="claude-logs"
|
||||||
|
|
@ -139,16 +335,55 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
||||||
'No logs yet.'
|
'No logs yet.'
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{showMoreVisible && (
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<div className="flex w-48 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
|
||||||
variant="ghost"
|
<Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||||
size="sm"
|
<input
|
||||||
className="h-7 px-2 text-xs"
|
type="text"
|
||||||
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
|
placeholder="Search logs..."
|
||||||
>
|
value={searchQuery}
|
||||||
Show more
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
</Button>
|
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>
|
||||||
|
|
||||||
<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 ? <p className="p-2 text-xs text-red-300">{error}</p> : null}
|
||||||
{!error && data.lines.length > 0 ? (
|
{!error && filteredText.trim().length > 0 ? (
|
||||||
<CliLogsRichView
|
<CliLogsRichView
|
||||||
// Parser expects chronological order; UI shows newest-first.
|
// Parser expects chronological order; UI shows newest-first.
|
||||||
cliLogsTail={normalizeToStreamJsonText(data.lines)}
|
cliLogsTail={filteredText}
|
||||||
order="newest-first"
|
order="newest-first"
|
||||||
className="max-h-[320px] p-2"
|
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}
|
) : null}
|
||||||
{!error && data.lines.length === 0 ? (
|
{!error && data.lines.length === 0 ? (
|
||||||
|
|
@ -171,6 +416,11 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
||||||
{loading ? 'Loading…' : 'No logs captured.'}
|
{loading ? 'Loading…' : 'No logs captured.'}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ import type { StreamJsonGroup } from '@renderer/utils/streamJsonParser';
|
||||||
interface CliLogsRichViewProps {
|
interface CliLogsRichViewProps {
|
||||||
cliLogsTail: string;
|
cliLogsTail: string;
|
||||||
order?: 'oldest-first' | 'newest-first';
|
order?: 'oldest-first' | 'newest-first';
|
||||||
|
onScroll?: (params: { scrollTop: number; scrollHeight: number; clientHeight: number }) => void;
|
||||||
|
containerRefCallback?: (el: HTMLDivElement | null) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,9 +132,11 @@ const StreamGroup = ({
|
||||||
export const CliLogsRichView = ({
|
export const CliLogsRichView = ({
|
||||||
cliLogsTail,
|
cliLogsTail,
|
||||||
order = 'oldest-first',
|
order = 'oldest-first',
|
||||||
|
onScroll,
|
||||||
|
containerRefCallback,
|
||||||
className,
|
className,
|
||||||
}: CliLogsRichViewProps): React.JSX.Element => {
|
}: 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)
|
// Tracks groups manually collapsed by user (default: all auto-expanded)
|
||||||
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(new Set());
|
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(new Set());
|
||||||
const [expandedItemIds, setExpandedItemIds] = 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;
|
const hasContent = cliLogsTail.trim().length > 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={(el) => {
|
||||||
|
scrollRef.current = el;
|
||||||
|
containerRefCallback?.(el);
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)]',
|
'max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)]',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
onScroll={(e) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
onScroll?.({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{hasContent ? (
|
{hasContent ? (
|
||||||
<pre className="p-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]">
|
<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;
|
const visibleGroups = order === 'newest-first' ? [...groups].reverse() : groups;
|
||||||
|
|
||||||
return (
|
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) =>
|
{visibleGroups.map((group) =>
|
||||||
group.items.length === 1 ? (
|
group.items.length === 1 ? (
|
||||||
// Single item — render flat without collapsible group wrapper
|
// 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 { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
|
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Bell,
|
Bell,
|
||||||
|
|
@ -304,6 +305,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
const [messagesFilter, setMessagesFilter] = useState<MessagesFilterState>({
|
const [messagesFilter, setMessagesFilter] = useState<MessagesFilterState>({
|
||||||
from: new Set(),
|
from: new Set(),
|
||||||
to: new Set(),
|
to: new Set(),
|
||||||
|
showNoise: false,
|
||||||
});
|
});
|
||||||
const [messagesFilterOpen, setMessagesFilterOpen] = useState(false);
|
const [messagesFilterOpen, setMessagesFilterOpen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -444,6 +446,24 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
[visibleContextTokens, lastAiGroupTotalTokens]
|
[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(() => {
|
useEffect(() => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
|
|
||||||
|
|
@ -558,6 +578,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
return ts >= timeWindow.start && ts < timeWindow.end;
|
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) {
|
if (messagesFilter.from.size > 0) {
|
||||||
list = list.filter((m) => m.from?.trim() && messagesFilter.from.has(m.from.trim()));
|
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={() => {
|
onClick={() => {
|
||||||
const next = !isContextPanelVisible;
|
const next = !isContextPanelVisible;
|
||||||
setContextPanelVisible(next);
|
setContextPanelVisible(next);
|
||||||
if (next && tabId && projectId && leadSessionId && !leadSessionLoaded) {
|
if (tabId && projectId && leadSessionId) {
|
||||||
void fetchSessionDetail(projectId, leadSessionId, tabId);
|
void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setIsContextButtonHovered(true)}
|
onMouseEnter={() => setIsContextButtonHovered(true)}
|
||||||
|
|
@ -899,11 +922,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
: leadSessionId
|
: leadSessionId
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{visibleContextPercentLabel
|
{visibleContextPercentLabel ?? 'Context'}
|
||||||
? visibleContextPercentLabel
|
|
||||||
: typeof leadContextPercent === 'number'
|
|
||||||
? `${Math.round(leadContextPercent)}%`
|
|
||||||
: 'Context'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -171,12 +171,14 @@ export const SendMessageDialog = ({
|
||||||
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
|
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
|
||||||
|
|
||||||
const trimmedText = textDraft.value.trim();
|
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 =
|
const canSend =
|
||||||
member.trim().length > 0 &&
|
member.trim().length > 0 &&
|
||||||
trimmedText.length > 0 &&
|
finalText.length > 0 &&
|
||||||
trimmedText.length <= MAX_MESSAGE_LENGTH &&
|
finalText.length <= MAX_MESSAGE_LENGTH &&
|
||||||
summary.trim().length > 0 &&
|
summary.trim().length > 0 &&
|
||||||
!sending &&
|
!sending &&
|
||||||
!attachmentsBlocked;
|
!attachmentsBlocked;
|
||||||
|
|
@ -191,8 +193,6 @@ export const SendMessageDialog = ({
|
||||||
|
|
||||||
const handleSubmit = (): void => {
|
const handleSubmit = (): void => {
|
||||||
if (!canSend) return;
|
if (!canSend) return;
|
||||||
const serialized = serializeChipsWithText(textDraft.value.trim(), chipDraft.chips);
|
|
||||||
const finalText = quote ? buildReplyBlock(quote.from, quote.text, serialized) : serialized;
|
|
||||||
onSend(
|
onSend(
|
||||||
member.trim(),
|
member.trim(),
|
||||||
finalText,
|
finalText,
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ export const TaskAttachments = ({
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [lightboxIndex, setLightboxIndex] = useState<number | 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 [thumbCache, setThumbCache] = useState<Map<string, string>>(new Map());
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
|
@ -130,9 +131,19 @@ export const TaskAttachments = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const idx = imageAttachments.findIndex((a) => a.id === att.id);
|
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
|
// Handle paste events for quick image attachment
|
||||||
|
|
@ -209,13 +220,11 @@ export const TaskAttachments = ({
|
||||||
{lightboxIndex !== null && (
|
{lightboxIndex !== null && (
|
||||||
<ImageLightbox
|
<ImageLightbox
|
||||||
open
|
open
|
||||||
onClose={() => setLightboxIndex(null)}
|
onClose={() => {
|
||||||
slides={imageAttachments
|
setLightboxIndex(null);
|
||||||
.map((att) => {
|
setLightboxSlides([]);
|
||||||
const dataUrl = thumbCache.get(att.id);
|
}}
|
||||||
return dataUrl ? { src: dataUrl, alt: att.filename } : null;
|
slides={lightboxSlides}
|
||||||
})
|
|
||||||
.filter(Boolean) as { src: string; alt: string }[]}
|
|
||||||
index={lightboxIndex}
|
index={lightboxIndex}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import type { InboxMessage } from '@shared/types';
|
||||||
export interface MessagesFilterState {
|
export interface MessagesFilterState {
|
||||||
from: Set<string>;
|
from: Set<string>;
|
||||||
to: Set<string>;
|
to: Set<string>;
|
||||||
|
/** When true, include internal coordination noise (idle/shutdown/etc.) */
|
||||||
|
showNoise: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MessagesFilterPopoverProps {
|
interface MessagesFilterPopoverProps {
|
||||||
|
|
@ -47,18 +49,23 @@ export const MessagesFilterPopover = ({
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onApply,
|
onApply,
|
||||||
}: MessagesFilterPopoverProps): React.JSX.Element => {
|
}: 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(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
const next = {
|
const next = {
|
||||||
from: new Set(filter.from),
|
from: new Set(filter.from),
|
||||||
to: new Set(filter.to),
|
to: new Set(filter.to),
|
||||||
|
showNoise: !!filter.showNoise,
|
||||||
};
|
};
|
||||||
const schedule = (): void => setDraft(next);
|
const schedule = (): void => setDraft(next);
|
||||||
queueMicrotask(schedule);
|
queueMicrotask(schedule);
|
||||||
}
|
}
|
||||||
}, [open, filter.from, filter.to]);
|
}, [open, filter.from, filter.to, filter.showNoise]);
|
||||||
|
|
||||||
const members = useStore((s) => s.selectedTeamData?.members ?? []);
|
const members = useStore((s) => s.selectedTeamData?.members ?? []);
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
|
|
@ -93,7 +100,7 @@ export const MessagesFilterPopover = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = (): void => {
|
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);
|
setDraft(empty);
|
||||||
onApply(empty);
|
onApply(empty);
|
||||||
};
|
};
|
||||||
|
|
@ -164,12 +171,21 @@ export const MessagesFilterPopover = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex justify-between gap-2 p-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 px-2 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
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}
|
onClick={handleReset}
|
||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,12 @@ export interface SessionDetailSlice {
|
||||||
tabSessionData: Record<string, TabSessionData>;
|
tabSessionData: Record<string, TabSessionData>;
|
||||||
|
|
||||||
// Actions
|
// 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 */
|
/** Refresh session without loading states or UI resets - for real-time updates */
|
||||||
refreshSessionInPlace: (projectId: string, sessionId: string) => Promise<void>;
|
refreshSessionInPlace: (projectId: string, sessionId: string) => Promise<void>;
|
||||||
setVisibleAIGroup: (aiGroupId: string | null) => void;
|
setVisibleAIGroup: (aiGroupId: string | null) => void;
|
||||||
|
|
@ -162,16 +167,23 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
||||||
tabSessionData: {},
|
tabSessionData: {},
|
||||||
|
|
||||||
// Fetch full session detail with chunks and subagents
|
// 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);
|
const requestGeneration = incrementTabGeneration(tabId);
|
||||||
set({
|
if (!options?.silent) {
|
||||||
sessionDetailLoading: true,
|
set({
|
||||||
sessionDetailError: null,
|
sessionDetailLoading: true,
|
||||||
conversationLoading: true,
|
sessionDetailError: null,
|
||||||
});
|
conversationLoading: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Also set per-tab loading state
|
// Also set per-tab loading state
|
||||||
if (tabId) {
|
if (tabId && !options?.silent) {
|
||||||
const prev = get().tabSessionData;
|
const prev = get().tabSessionData;
|
||||||
set({
|
set({
|
||||||
tabSessionData: {
|
tabSessionData: {
|
||||||
|
|
@ -461,10 +473,12 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
||||||
sessionPhaseInfo: phaseInfo,
|
sessionPhaseInfo: phaseInfo,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
set({
|
if (!options?.silent) {
|
||||||
sessionDetailLoading: false,
|
set({
|
||||||
conversationLoading: false,
|
sessionDetailLoading: false,
|
||||||
});
|
conversationLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('fetchSessionDetail error:', error);
|
logger.error('fetchSessionDetail error:', error);
|
||||||
|
|
@ -472,14 +486,16 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const errorMsg = error instanceof Error ? error.message : 'Failed to fetch session detail';
|
const errorMsg = error instanceof Error ? error.message : 'Failed to fetch session detail';
|
||||||
set({
|
if (!options?.silent) {
|
||||||
sessionDetailError: errorMsg,
|
set({
|
||||||
sessionDetailLoading: false,
|
sessionDetailError: errorMsg,
|
||||||
conversationLoading: false,
|
sessionDetailLoading: false,
|
||||||
});
|
conversationLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Store per-tab error state
|
// Store per-tab error state
|
||||||
if (tabId) {
|
if (tabId && !options?.silent) {
|
||||||
const prev = get().tabSessionData;
|
const prev = get().tabSessionData;
|
||||||
set({
|
set({
|
||||||
tabSessionData: {
|
tabSessionData: {
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ export function computePercentOfTotal(
|
||||||
visibleTokens: number,
|
visibleTokens: number,
|
||||||
totalSessionTokens: number | undefined
|
totalSessionTokens: number | undefined
|
||||||
): number | null {
|
): number | null {
|
||||||
if (!Number.isFinite(visibleTokens) || visibleTokens <= 0) return 0;
|
|
||||||
if (totalSessionTokens === undefined || totalSessionTokens <= 0) return null;
|
if (totalSessionTokens === undefined || totalSessionTokens <= 0) return null;
|
||||||
|
if (!Number.isFinite(visibleTokens) || visibleTokens <= 0) return 0;
|
||||||
return Math.min((visibleTokens / totalSessionTokens) * 100, 100);
|
return Math.min((visibleTokens / totalSessionTokens) * 100, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -174,13 +174,14 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
|
||||||
let currentItems: AIGroupDisplayItem[] = [];
|
let currentItems: AIGroupDisplayItem[] = [];
|
||||||
let currentTimestamp: Date | null = null;
|
let currentTimestamp: Date | null = null;
|
||||||
let currentGroupId: string | 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)
|
// Stable timestamp for the entire parse (deterministic across re-renders)
|
||||||
const parseTimestamp = new Date();
|
const parseTimestamp = new Date();
|
||||||
|
|
||||||
const flushGroup = (): void => {
|
const flushGroup = (): void => {
|
||||||
if (currentItems.length > 0 && currentTimestamp) {
|
if (currentItems.length > 0 && currentTimestamp) {
|
||||||
const id = currentGroupId ?? `stream-group-${groupCounter++}`;
|
const id = currentGroupId ?? `stream-group-fallback-${groups.length}`;
|
||||||
groups.push({
|
groups.push({
|
||||||
id,
|
id,
|
||||||
items: currentItems,
|
items: currentItems,
|
||||||
|
|
@ -221,7 +222,15 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
|
||||||
if (!currentTimestamp) currentTimestamp = parseTimestamp;
|
if (!currentTimestamp) currentTimestamp = parseTimestamp;
|
||||||
if (!currentGroupId) {
|
if (!currentGroupId) {
|
||||||
const msgId = extractAssistantMessageId(parsed);
|
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);
|
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