feat: update TeamProvisioningService and UI components for enhanced user experience and functionality
- Increased PREFLIGHT_TIMEOUT_MS to 60 seconds for improved preflight checks. - Updated PREFLIGHT_PING_PROMPT for clarity and added new arguments to PREFLIGHT_PING_ARGS for enhanced probing. - Introduced isTransientProbeWarning function to better handle transient warnings during probe operations. - Enhanced readiness messages in TeamProvisioningService to provide clearer feedback based on warning states. - Improved ClaudeLogsSection to reflect team status more accurately in log messages. - Simplified message sending behavior in SendMessageDialog and TaskCommentInput by removing modifier key references. - Added subagent message preview functionality in MemberLogsTab for better task tracking and visibility.
This commit is contained in:
parent
6a67838d20
commit
218189b241
10 changed files with 627 additions and 247 deletions
|
|
@ -62,7 +62,7 @@ const UI_LOGS_TAIL_LIMIT = 128 * 1024;
|
|||
const SHELL_ENV_TIMEOUT_MS = 12000;
|
||||
// const CLI_PREPARE_TIMEOUT_MS = 10000;
|
||||
const PROBE_CACHE_TTL_MS = 10 * 60_000;
|
||||
const PREFLIGHT_TIMEOUT_MS = 30000;
|
||||
const PREFLIGHT_TIMEOUT_MS = 60000;
|
||||
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
||||
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
||||
const FS_MONITOR_POLL_MS = 2000;
|
||||
|
|
@ -70,8 +70,20 @@ const TASK_WAIT_FALLBACK_MS = 15_000;
|
|||
const TEAM_JSON_READ_TIMEOUT_MS = 5_000;
|
||||
const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const TEAM_INBOX_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const PREFLIGHT_PING_PROMPT = 'Reply with the single word PONG and nothing else';
|
||||
const PREFLIGHT_PING_ARGS = ['-p', PREFLIGHT_PING_PROMPT, '--output-format', 'text'] as const;
|
||||
const PREFLIGHT_PING_PROMPT = 'Output only the single word PONG.';
|
||||
const PREFLIGHT_PING_ARGS = [
|
||||
'-p',
|
||||
PREFLIGHT_PING_PROMPT,
|
||||
'--output-format',
|
||||
'text',
|
||||
'--model',
|
||||
'haiku',
|
||||
'--max-turns',
|
||||
'1',
|
||||
'--max-budget-usd',
|
||||
'0.05',
|
||||
'--no-session-persistence',
|
||||
] as const;
|
||||
const PREFLIGHT_EXPECTED = 'PONG';
|
||||
|
||||
type TeamsBaseLocation = 'configured' | 'default';
|
||||
|
|
@ -1008,8 +1020,23 @@ interface CachedProbeResult {
|
|||
}
|
||||
|
||||
let cachedProbeResult: CachedProbeResult | null = null;
|
||||
let probeInFlight: Promise<{ claudePath: string; authSource: ProvisioningAuthSource; warning?: string } | null> | null =
|
||||
null;
|
||||
let probeInFlight: Promise<{
|
||||
claudePath: string;
|
||||
authSource: ProvisioningAuthSource;
|
||||
warning?: string;
|
||||
} | null> | null = null;
|
||||
|
||||
function isTransientProbeWarning(warning: string): boolean {
|
||||
const lower = warning.toLowerCase();
|
||||
return (
|
||||
lower.includes('timeout running:') ||
|
||||
lower.includes('did not complete') ||
|
||||
lower.includes('timed out') ||
|
||||
lower.includes('etimedout') ||
|
||||
lower.includes('econnreset') ||
|
||||
lower.includes('eai_again')
|
||||
);
|
||||
}
|
||||
|
||||
export class TeamProvisioningService {
|
||||
private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000;
|
||||
|
|
@ -1046,7 +1073,9 @@ export class TeamProvisioningService {
|
|||
const offsetRaw = query?.offset ?? 0;
|
||||
const limitRaw = query?.limit ?? 100;
|
||||
const offset = Number.isFinite(offsetRaw) ? Math.max(0, Math.floor(offsetRaw)) : 0;
|
||||
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100;
|
||||
const limit = Number.isFinite(limitRaw)
|
||||
? Math.max(1, Math.min(1000, Math.floor(limitRaw)))
|
||||
: 100;
|
||||
|
||||
const total = run.claudeLogLines.length;
|
||||
if (total === 0) {
|
||||
|
|
@ -1057,8 +1086,10 @@ export class TeamProvisioningService {
|
|||
const oldestInclusive = Math.max(0, newestExclusive - limit);
|
||||
const normalizeLine = (line: string): string => {
|
||||
// Back-compat: older builds prefixed every line with "[stdout] " / "[stderr] "
|
||||
if (line.startsWith('[stdout] ') && line !== '[stdout]') return line.slice('[stdout] '.length);
|
||||
if (line.startsWith('[stderr] ') && line !== '[stderr]') return line.slice('[stderr] '.length);
|
||||
if (line.startsWith('[stdout] ') && line !== '[stdout]')
|
||||
return line.slice('[stdout] '.length);
|
||||
if (line.startsWith('[stderr] ') && line !== '[stderr]')
|
||||
return line.slice('[stderr] '.length);
|
||||
return line;
|
||||
};
|
||||
|
||||
|
|
@ -1201,7 +1232,8 @@ export class TeamProvisioningService {
|
|||
|
||||
async warmup(): Promise<void> {
|
||||
try {
|
||||
if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS) return;
|
||||
if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS)
|
||||
return;
|
||||
const result = await this.getCachedOrProbeResult(process.cwd());
|
||||
if (!result) return;
|
||||
logger.info('CLI warmup completed');
|
||||
|
|
@ -1226,7 +1258,11 @@ export class TeamProvisioningService {
|
|||
const ready = !warning || authSource !== 'none' || !isAuthFailure;
|
||||
return {
|
||||
ready,
|
||||
message: ready ? 'CLI is warmed up and ready to launch' : warning || 'CLI is not ready',
|
||||
message: ready
|
||||
? warnings.length > 0
|
||||
? 'CLI is ready to launch (see notes)'
|
||||
: 'CLI is warmed up and ready to launch'
|
||||
: warning || 'CLI is not ready',
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
}
|
||||
|
|
@ -1267,7 +1303,10 @@ export class TeamProvisioningService {
|
|||
|
||||
return {
|
||||
ready: true,
|
||||
message: 'CLI is warmed up and ready to launch',
|
||||
message:
|
||||
warnings.length > 0
|
||||
? 'CLI is ready to launch (see notes)'
|
||||
: 'CLI is warmed up and ready to launch',
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
}
|
||||
|
|
@ -1287,7 +1326,11 @@ export class TeamProvisioningService {
|
|||
): Promise<{ claudePath: string; authSource: ProvisioningAuthSource; warning?: string } | null> {
|
||||
const cached = this.getFreshCachedProbeResult();
|
||||
if (cached) {
|
||||
return { claudePath: cached.claudePath, authSource: cached.authSource, warning: cached.warning };
|
||||
return {
|
||||
claudePath: cached.claudePath,
|
||||
authSource: cached.authSource,
|
||||
warning: cached.warning,
|
||||
};
|
||||
}
|
||||
|
||||
if (probeInFlight) {
|
||||
|
|
@ -1300,12 +1343,20 @@ export class TeamProvisioningService {
|
|||
|
||||
const { env, authSource } = await this.buildProvisioningEnv();
|
||||
const probe = await this.probeClaudeRuntime(claudePath, cwd, env);
|
||||
const result = { claudePath, authSource, ...(probe.warning ? { warning: probe.warning } : {}) };
|
||||
const result = {
|
||||
claudePath,
|
||||
authSource,
|
||||
...(probe.warning ? { warning: probe.warning } : {}),
|
||||
};
|
||||
|
||||
if (!probe.warning || !this.isAuthFailureWarning(probe.warning)) {
|
||||
const shouldCache =
|
||||
!probe.warning ||
|
||||
(!this.isAuthFailureWarning(probe.warning) && !isTransientProbeWarning(probe.warning));
|
||||
|
||||
if (shouldCache) {
|
||||
cachedProbeResult = { ...result, cachedAtMs: Date.now() };
|
||||
} else {
|
||||
// Don't pin auth failures in cache — user may log in externally and retry.
|
||||
// Don't pin auth failures / transient failures in cache — user may fix and retry.
|
||||
cachedProbeResult = null;
|
||||
}
|
||||
|
||||
|
|
@ -2819,7 +2870,6 @@ export class TeamProvisioningService {
|
|||
'team-lead';
|
||||
const leadMsg: InboxMessage = {
|
||||
from: leadName,
|
||||
to: 'user',
|
||||
text: cleanText,
|
||||
timestamp: nowIso(),
|
||||
read: true,
|
||||
|
|
@ -2831,7 +2881,10 @@ export class TeamProvisioningService {
|
|||
run.leadTextPushedInCurrentTurn = true;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - run.lastLeadTextEmitMs >= TeamProvisioningService.LEAD_TEXT_EMIT_THROTTLE_MS) {
|
||||
if (
|
||||
now - run.lastLeadTextEmitMs >=
|
||||
TeamProvisioningService.LEAD_TEXT_EMIT_THROTTLE_MS
|
||||
) {
|
||||
run.lastLeadTextEmitMs = now;
|
||||
this.teamChangeEmitter?.({
|
||||
type: 'inbox',
|
||||
|
|
@ -3126,9 +3179,7 @@ export class TeamProvisioningService {
|
|||
| undefined;
|
||||
const trigger = typeof meta?.trigger === 'string' ? meta.trigger : 'auto';
|
||||
const preTokens = typeof meta?.pre_tokens === 'number' ? meta.pre_tokens : null;
|
||||
const tokenInfo = preTokens
|
||||
? ` (was ~${(preTokens / 1000).toFixed(0)}k tokens)`
|
||||
: '';
|
||||
const tokenInfo = preTokens ? ` (was ~${(preTokens / 1000).toFixed(0)}k tokens)` : '';
|
||||
|
||||
const compactMsg: InboxMessage = {
|
||||
from: 'system',
|
||||
|
|
@ -3160,7 +3211,12 @@ export class TeamProvisioningService {
|
|||
private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise<void> {
|
||||
// Guard: must be set synchronously BEFORE any await to prevent
|
||||
// double-invocation from filesystem monitor + stream-json racing.
|
||||
if (run.provisioningComplete || run.cancelRequested || run.processKilled || run.progress.state === 'failed')
|
||||
if (
|
||||
run.provisioningComplete ||
|
||||
run.cancelRequested ||
|
||||
run.processKilled ||
|
||||
run.progress.state === 'failed'
|
||||
)
|
||||
return;
|
||||
|
||||
// Prevent false "ready" when auth failure was printed as assistant text or logs
|
||||
|
|
@ -3172,7 +3228,11 @@ export class TeamProvisioningService {
|
|||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.trim();
|
||||
if (preCompleteText && this.hasApiError(preCompleteText) && !this.isAuthFailureWarning(preCompleteText)) {
|
||||
if (
|
||||
preCompleteText &&
|
||||
this.hasApiError(preCompleteText) &&
|
||||
!this.isAuthFailureWarning(preCompleteText)
|
||||
) {
|
||||
this.failProvisioningWithApiError(run, preCompleteText);
|
||||
return;
|
||||
}
|
||||
|
|
@ -3976,7 +4036,9 @@ export class TeamProvisioningService {
|
|||
if (membersRaw.length > 0) {
|
||||
const teammateNames = membersRaw
|
||||
.map((m) => (typeof m.name === 'string' ? m.name.trim() : ''))
|
||||
.filter((n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user');
|
||||
.filter(
|
||||
(n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user'
|
||||
);
|
||||
|
||||
const keepName = createCliAutoSuffixNameGuard(teammateNames);
|
||||
const nextMembers: Record<string, unknown>[] = [];
|
||||
|
|
@ -4015,7 +4077,9 @@ export class TeamProvisioningService {
|
|||
const activeNames = metaMembers
|
||||
.filter((m) => !m.removedAt)
|
||||
.map((m) => m.name.trim())
|
||||
.filter((n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user');
|
||||
.filter(
|
||||
(n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user'
|
||||
);
|
||||
|
||||
const keepName = createCliAutoSuffixNameGuard(activeNames);
|
||||
const removedFromMeta: string[] = [];
|
||||
|
|
@ -4042,7 +4106,9 @@ export class TeamProvisioningService {
|
|||
nextMeta
|
||||
.filter((m) => !m.removedAt)
|
||||
.map((m) => m.name.trim())
|
||||
.filter((n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user')
|
||||
.filter(
|
||||
(n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user'
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -4658,7 +4724,13 @@ export class TeamProvisioningService {
|
|||
[...PREFLIGHT_PING_ARGS],
|
||||
cwd,
|
||||
env,
|
||||
PREFLIGHT_TIMEOUT_MS
|
||||
PREFLIGHT_TIMEOUT_MS,
|
||||
{
|
||||
resolveOnOutputMatch: ({ stdout, stderr }) => {
|
||||
const combined = `${stdout}\n${stderr}`.trim();
|
||||
return /\bPONG\b/i.test(combined);
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
|
@ -4705,7 +4777,7 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
const pongCandidate = pingProbe.stdout.trim() || pingProbe.stderr.trim();
|
||||
const isPong = pongCandidate.toUpperCase() === PREFLIGHT_EXPECTED;
|
||||
const isPong = new RegExp(`\\b${PREFLIGHT_EXPECTED}\\b`, 'i').test(pongCandidate);
|
||||
if (!isPong) {
|
||||
return {
|
||||
warning:
|
||||
|
|
@ -4730,7 +4802,15 @@ export class TeamProvisioningService {
|
|||
args: string[],
|
||||
cwd: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
timeoutMs: number
|
||||
timeoutMs: number,
|
||||
options?: {
|
||||
/**
|
||||
* Optional early success predicate. If this returns true based on
|
||||
* buffered stdout/stderr, the probe resolves immediately (and the process
|
||||
* is best-effort terminated) instead of waiting for `close`.
|
||||
*/
|
||||
resolveOnOutputMatch?: (ctx: { stdout: string; stderr: string }) => boolean;
|
||||
}
|
||||
): Promise<{ exitCode: number | null; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawnCli(claudePath, args, {
|
||||
|
|
@ -4738,26 +4818,52 @@ export class TeamProvisioningService {
|
|||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
let stdoutText = '';
|
||||
let stderrText = '';
|
||||
let settled = false;
|
||||
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
settled = true;
|
||||
killProcessTree(child);
|
||||
reject(new Error(`Timeout running: claude ${args.join(' ')}`));
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
|
||||
child.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
|
||||
const maybeResolveEarly = (): void => {
|
||||
if (settled) return;
|
||||
if (!options?.resolveOnOutputMatch) return;
|
||||
const ctx = { stdout: stdoutText.trim(), stderr: stderrText.trim() };
|
||||
if (!options.resolveOnOutputMatch(ctx)) return;
|
||||
|
||||
settled = true;
|
||||
clearTimeout(timeoutHandle);
|
||||
// If the process printed the match but hangs during teardown, don't
|
||||
// block the UI; terminate best-effort and resolve.
|
||||
killProcessTree(child);
|
||||
resolve({ exitCode: 0, stdout: ctx.stdout, stderr: ctx.stderr });
|
||||
};
|
||||
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
stdoutText += chunk.toString('utf8');
|
||||
maybeResolveEarly();
|
||||
});
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderrText += chunk.toString('utf8');
|
||||
maybeResolveEarly();
|
||||
});
|
||||
child.once('error', (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeoutHandle);
|
||||
reject(error);
|
||||
});
|
||||
child.once('close', (exitCode) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeoutHandle);
|
||||
resolve({
|
||||
exitCode,
|
||||
stdout: Buffer.concat(stdoutChunks).toString('utf8').trim(),
|
||||
stderr: Buffer.concat(stderrChunks).toString('utf8').trim(),
|
||||
stdout: stdoutText.trim(),
|
||||
stderr: stderrText.trim(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,14 +3,12 @@ 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 { useStore } from '@renderer/store';
|
||||
import { Search, Terminal, X } from 'lucide-react';
|
||||
|
||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||
import { CliLogsRichView } from './CliLogsRichView';
|
||||
import {
|
||||
ClaudeLogsFilterPopover,
|
||||
DEFAULT_CLAUDE_LOGS_FILTER,
|
||||
} from './ClaudeLogsFilterPopover';
|
||||
import { ClaudeLogsFilterPopover, DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover';
|
||||
|
||||
import type { TeamClaudeLogsResponse } from '@shared/types';
|
||||
import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover';
|
||||
|
|
@ -108,7 +106,10 @@ function filterStreamJsonText(
|
|||
return null;
|
||||
};
|
||||
|
||||
const writeBlocks = (parsed: Record<string, unknown>, blocks: AssistantContentBlock[]): Record<string, unknown> => {
|
||||
const writeBlocks = (
|
||||
parsed: Record<string, unknown>,
|
||||
blocks: AssistantContentBlock[]
|
||||
): Record<string, unknown> => {
|
||||
if (Array.isArray(parsed.content)) {
|
||||
return { ...parsed, content: blocks };
|
||||
}
|
||||
|
|
@ -188,6 +189,7 @@ function filterStreamJsonText(
|
|||
}
|
||||
|
||||
export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.JSX.Element => {
|
||||
const isAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false);
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
const [data, setData] = useState<TeamClaudeLogsResponse>({ lines: [], total: 0, hasMore: false });
|
||||
const [pending, setPending] = useState<TeamClaudeLogsResponse | null>(null);
|
||||
|
|
@ -233,12 +235,16 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
|||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const computeNewCount = (committed: TeamClaudeLogsResponse, latest: TeamClaudeLogsResponse): number => {
|
||||
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);
|
||||
const diff =
|
||||
(latest.total ?? latest.lines.length) - (committed.total ?? committed.lines.length);
|
||||
return Math.max(0, diff);
|
||||
};
|
||||
|
||||
|
|
@ -331,8 +337,10 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
|||
Showing <span className="font-mono">{Math.min(data.total, visibleCount)}</span> of{' '}
|
||||
<span className="font-mono">{data.total}</span>
|
||||
</>
|
||||
) : (
|
||||
) : isAlive ? (
|
||||
'No logs yet.'
|
||||
) : (
|
||||
'Team is not running.'
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -366,7 +374,7 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
className="h-7 border-blue-500/30 bg-blue-600 px-2 text-xs text-white hover:bg-blue-500"
|
||||
onClick={applyPending}
|
||||
title="Show newest logs"
|
||||
>
|
||||
|
|
@ -386,12 +394,7 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'rounded',
|
||||
loading && 'opacity-80'
|
||||
)}
|
||||
>
|
||||
<div className={cn('rounded', loading && 'opacity-80')}>
|
||||
{error ? <p className="p-2 text-xs text-red-300">{error}</p> : null}
|
||||
{!error && filteredText.trim().length > 0 ? (
|
||||
<CliLogsRichView
|
||||
|
|
@ -414,16 +417,13 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
|||
) : null}
|
||||
{!error && data.lines.length === 0 ? (
|
||||
<p className="p-2 text-xs text-[var(--color-text-muted)]">
|
||||
{loading ? 'Loading…' : 'No logs captured.'}
|
||||
{loading ? 'Loading…' : isAlive ? 'No logs captured.' : 'Team is not running.'}
|
||||
</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>
|
||||
<p className="p-2 text-xs text-[var(--color-text-muted)]">No matching logs.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</CollapsibleTeamSection>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
|||
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
||||
import { AlertCircle, ImagePlus, Send, X } from 'lucide-react';
|
||||
|
||||
import { MemberBadge } from '../MemberBadge';
|
||||
|
|
@ -129,7 +128,16 @@ export const SendMessageDialog = ({
|
|||
}
|
||||
}
|
||||
prevOpenRef.current = open;
|
||||
}, [open, defaultRecipient, defaultText, defaultChip, quotedMessage, lastResult, textDraft, chipDraft]);
|
||||
}, [
|
||||
open,
|
||||
defaultRecipient,
|
||||
defaultText,
|
||||
defaultChip,
|
||||
quotedMessage,
|
||||
lastResult,
|
||||
textDraft,
|
||||
chipDraft,
|
||||
]);
|
||||
|
||||
// Track whether auto-close is needed (avoid setState in render)
|
||||
useEffect(() => {
|
||||
|
|
@ -381,7 +389,7 @@ export const SendMessageDialog = ({
|
|||
<MentionableTextarea
|
||||
id="smd-message"
|
||||
className={quote ? 'rounded-t-none' : undefined}
|
||||
placeholder={`Write your message... (${getModifierKeyName()}+Enter to send)`}
|
||||
placeholder="Write your message... (Enter to send)"
|
||||
value={textDraft.value}
|
||||
onValueChange={textDraft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
|
|
@ -421,7 +429,9 @@ export const SendMessageDialog = ({
|
|||
</span>
|
||||
) : null}
|
||||
{textDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">
|
||||
Draft saved
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
|
|
@ -442,7 +452,6 @@ export const SendMessageDialog = ({
|
|||
Shown as notification preview. Team lead also sees this for peer messages.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
|||
import { useStore } from '@renderer/store';
|
||||
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react';
|
||||
|
||||
|
|
@ -71,50 +70,45 @@ export const TaskCommentInput = ({
|
|||
trimmed.length <= MAX_COMMENT_LENGTH &&
|
||||
!addingComment;
|
||||
|
||||
const addFiles = useCallback(
|
||||
(files: FileList | File[]) => {
|
||||
setAttachError(null);
|
||||
const fileArray = Array.from(files);
|
||||
for (const file of fileArray) {
|
||||
if (!ACCEPTED_TYPES.has(file.type)) {
|
||||
setAttachError(`Unsupported type: ${file.type}`);
|
||||
continue;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setAttachError(
|
||||
`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
const base64 = result.split(',')[1];
|
||||
if (!base64) return;
|
||||
const id = crypto.randomUUID();
|
||||
setPendingAttachments((prev) => {
|
||||
if (prev.length >= MAX_ATTACHMENTS) {
|
||||
setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`);
|
||||
return prev;
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
base64Data: base64,
|
||||
previewUrl: result,
|
||||
size: file.size,
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
const addFiles = useCallback((files: FileList | File[]) => {
|
||||
setAttachError(null);
|
||||
const fileArray = Array.from(files);
|
||||
for (const file of fileArray) {
|
||||
if (!ACCEPTED_TYPES.has(file.type)) {
|
||||
setAttachError(`Unsupported type: ${file.type}`);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setAttachError(`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`);
|
||||
continue;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
const base64 = result.split(',')[1];
|
||||
if (!base64) return;
|
||||
const id = crypto.randomUUID();
|
||||
setPendingAttachments((prev) => {
|
||||
if (prev.length >= MAX_ATTACHMENTS) {
|
||||
setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`);
|
||||
return prev;
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
base64Data: base64,
|
||||
previewUrl: result,
|
||||
size: file.size,
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const removeAttachment = useCallback((id: string) => {
|
||||
setPendingAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||
|
|
@ -250,7 +244,7 @@ export const TaskCommentInput = ({
|
|||
/>
|
||||
<MentionableTextarea
|
||||
id={`task-comment-${taskId}`}
|
||||
placeholder={`Add a comment... (${getModifierKeyName()}+Enter to send)`}
|
||||
placeholder="Add a comment... (Enter to send)"
|
||||
value={draft.value}
|
||||
onValueChange={draft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import { useStore } from '@renderer/store';
|
|||
import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
||||
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
|
@ -169,125 +168,128 @@ export const TaskCommentsSection = ({
|
|||
) : null}
|
||||
|
||||
<div className={containerClassName ?? ''}>
|
||||
{visibleComments.map((comment, index) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className={[
|
||||
'group px-4 py-2.5',
|
||||
comment.type === 'review_approved'
|
||||
? 'border-y border-emerald-500/20 bg-emerald-500/5'
|
||||
: comment.type === 'review_request'
|
||||
? 'border-y border-blue-500/20 bg-blue-500/5'
|
||||
: '',
|
||||
].join(' ')}
|
||||
style={
|
||||
!comment.type || comment.type === 'regular'
|
||||
? { backgroundColor: index % 2 === 1 ? 'var(--card-bg-zebra)' : 'var(--card-bg)' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
<MemberBadge name={comment.author} color={colorMap.get(comment.author)} />
|
||||
{comment.type === 'review_approved' ? (
|
||||
<span className="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">
|
||||
<CheckCircle2 size={10} />
|
||||
Approved
|
||||
{visibleComments.map((comment, index) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className={[
|
||||
'group px-4 py-2.5',
|
||||
comment.type === 'review_approved'
|
||||
? 'border-y border-emerald-500/20 bg-emerald-500/5'
|
||||
: comment.type === 'review_request'
|
||||
? 'border-y border-blue-500/20 bg-blue-500/5'
|
||||
: '',
|
||||
].join(' ')}
|
||||
style={
|
||||
!comment.type || comment.type === 'regular'
|
||||
? {
|
||||
backgroundColor:
|
||||
index % 2 === 1 ? 'var(--card-bg-zebra)' : 'var(--card-bg)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
<MemberBadge name={comment.author} color={colorMap.get(comment.author)} />
|
||||
{comment.type === 'review_approved' ? (
|
||||
<span className="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">
|
||||
<CheckCircle2 size={10} />
|
||||
Approved
|
||||
</span>
|
||||
) : comment.type === 'review_request' ? (
|
||||
<span className="inline-flex items-center gap-0.5 rounded-full bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-400">
|
||||
<Eye size={10} />
|
||||
Review requested
|
||||
</span>
|
||||
) : null}
|
||||
<span>
|
||||
{(() => {
|
||||
const date = new Date(comment.createdAt);
|
||||
return isNaN(date.getTime())
|
||||
? 'unknown time'
|
||||
: formatDistanceToNow(date, { addSuffix: true });
|
||||
})()}
|
||||
</span>
|
||||
) : comment.type === 'review_request' ? (
|
||||
<span className="inline-flex items-center gap-0.5 rounded-full bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-400">
|
||||
<Eye size={10} />
|
||||
Review requested
|
||||
</span>
|
||||
) : null}
|
||||
<span>
|
||||
{(() => {
|
||||
const date = new Date(comment.createdAt);
|
||||
return isNaN(date.getTime())
|
||||
? 'unknown time'
|
||||
: formatDistanceToNow(date, { addSuffix: true });
|
||||
})()}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
const replyText = stripAgentBlocks(
|
||||
parseMessageReply(comment.text)?.replyText ?? comment.text
|
||||
);
|
||||
if (onReply) {
|
||||
onReply(comment.author, replyText);
|
||||
} else {
|
||||
setReplyTo({ author: comment.author, text: replyText });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Reply size={11} />
|
||||
Reply
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Reply to comment</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{(() => {
|
||||
const reply = parseMessageReply(comment.text);
|
||||
const rawForDisplay = reply ? reply.replyText : comment.text;
|
||||
const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay));
|
||||
return (
|
||||
<ExpandableContent collapsedHeight={120} className="text-xs">
|
||||
{reply ? (
|
||||
<ReplyQuoteBlock
|
||||
reply={{
|
||||
...reply,
|
||||
originalText: stripAgentBlocks(reply.originalText),
|
||||
replyText: stripAgentBlocks(reply.replyText),
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
const replyText = stripAgentBlocks(
|
||||
parseMessageReply(comment.text)?.replyText ?? comment.text
|
||||
);
|
||||
if (onReply) {
|
||||
onReply(comment.author, replyText);
|
||||
} else {
|
||||
setReplyTo({ author: comment.author, text: replyText });
|
||||
}
|
||||
}}
|
||||
memberColor={colorMap.get(reply.agentName)}
|
||||
bodyMaxHeight="max-h-none"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClickCapture={
|
||||
onTaskIdClick
|
||||
? (e) => {
|
||||
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
|
||||
'a[href^="task://"]'
|
||||
);
|
||||
if (link) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const id = link.getAttribute('href')?.replace('task://', '');
|
||||
if (id) onTaskIdClick(id);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<MarkdownViewer
|
||||
content={(() => {
|
||||
let t = linkifyTaskIdsInMarkdown(displayText);
|
||||
if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap);
|
||||
return t;
|
||||
})()}
|
||||
maxHeight="max-h-none"
|
||||
bare
|
||||
<Reply size={11} />
|
||||
Reply
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Reply to comment</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{(() => {
|
||||
const reply = parseMessageReply(comment.text);
|
||||
const rawForDisplay = reply ? reply.replyText : comment.text;
|
||||
const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay));
|
||||
return (
|
||||
<ExpandableContent collapsedHeight={120} className="text-xs">
|
||||
{reply ? (
|
||||
<ReplyQuoteBlock
|
||||
reply={{
|
||||
...reply,
|
||||
originalText: stripAgentBlocks(reply.originalText),
|
||||
replyText: stripAgentBlocks(reply.replyText),
|
||||
}}
|
||||
memberColor={colorMap.get(reply.agentName)}
|
||||
bodyMaxHeight="max-h-none"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</ExpandableContent>
|
||||
);
|
||||
})()}
|
||||
{comment.attachments && comment.attachments.length > 0 ? (
|
||||
<CommentAttachments
|
||||
attachments={comment.attachments}
|
||||
teamName={teamName}
|
||||
taskId={taskId}
|
||||
onPreview={setPreviewImageUrl}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
<span
|
||||
onClickCapture={
|
||||
onTaskIdClick
|
||||
? (e) => {
|
||||
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
|
||||
'a[href^="task://"]'
|
||||
);
|
||||
if (link) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const id = link.getAttribute('href')?.replace('task://', '');
|
||||
if (id) onTaskIdClick(id);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<MarkdownViewer
|
||||
content={(() => {
|
||||
let t = linkifyTaskIdsInMarkdown(displayText);
|
||||
if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap);
|
||||
return t;
|
||||
})()}
|
||||
maxHeight="max-h-none"
|
||||
bare
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</ExpandableContent>
|
||||
);
|
||||
})()}
|
||||
{comment.attachments && comment.attachments.length > 0 ? (
|
||||
<CommentAttachments
|
||||
attachments={comment.attachments}
|
||||
teamName={teamName}
|
||||
taskId={taskId}
|
||||
onPreview={setPreviewImageUrl}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sortedComments.length > visibleComments.length ? (
|
||||
|
|
@ -347,7 +349,7 @@ export const TaskCommentsSection = ({
|
|||
<div className="relative">
|
||||
<MentionableTextarea
|
||||
id={`task-comment-${taskId}`}
|
||||
placeholder={`Add a comment... (${getModifierKeyName()}+Enter to send)`}
|
||||
placeholder="Add a comment... (Enter to send)"
|
||||
value={draft.value}
|
||||
onValueChange={draft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ export const TaskDetailDialog = ({
|
|||
const updateTaskFields = useStore((s) => s.updateTaskFields);
|
||||
|
||||
const [logsRefreshing, setLogsRefreshing] = useState(false);
|
||||
const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false);
|
||||
|
||||
// Inline editing: subject
|
||||
const [editingSubject, setEditingSubject] = useState(false);
|
||||
|
|
@ -289,6 +290,11 @@ export const TaskDetailDialog = ({
|
|||
.map((t) => t.id);
|
||||
const isTodo = status === 'pending' && !kanbanColumn;
|
||||
const canReassign = isTodo && onOwnerChange;
|
||||
const leadName =
|
||||
members.find((m) => m.agentType === 'team-lead' || m.name === 'team-lead')?.name ?? 'team-lead';
|
||||
const isLeadOwnedTask =
|
||||
(currentTask.owner ?? '').trim().toLowerCase() === leadName.trim().toLowerCase() ||
|
||||
(currentTask.owner ?? '').trim().toLowerCase() === 'team-lead';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
|
|
@ -647,10 +653,23 @@ export const TaskDetailDialog = ({
|
|||
title="Execution Logs"
|
||||
icon={<ScrollText size={14} />}
|
||||
headerExtra={
|
||||
logsRefreshing ? (
|
||||
<span className="flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
|
||||
<Loader2 size={10} className="animate-spin" />
|
||||
Updating...
|
||||
logsRefreshing || executionPreviewOnline ? (
|
||||
<span className="flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
{executionPreviewOnline ? (
|
||||
<span
|
||||
className="pointer-events-none relative inline-flex size-2 shrink-0"
|
||||
title="Online"
|
||||
>
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
) : null}
|
||||
{logsRefreshing ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Loader2 size={10} className="animate-spin" />
|
||||
Updating...
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
|
|
@ -667,6 +686,11 @@ export const TaskDetailDialog = ({
|
|||
taskStatus={currentTask.status}
|
||||
taskWorkIntervals={currentTask.workIntervals}
|
||||
onRefreshingChange={setLogsRefreshing}
|
||||
// Only show a "latest messages" preview when this task is owned by a subagent.
|
||||
// For lead-owned tasks, the lead session is a mixed stream (lead + multiple agents),
|
||||
// so filtering to "just the member messages" is unreliable and easy to mislead.
|
||||
showSubagentPreview={Boolean(currentTask.owner) && !isLeadOwnedTask}
|
||||
onPreviewOnlineChange={setExecutionPreviewOnline}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTeamSection>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||
|
||||
import { api } from '@renderer/api';
|
||||
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
|
||||
import {
|
||||
SubagentRecentMessagesPreview,
|
||||
type SubagentPreviewMessage,
|
||||
} from '@renderer/components/team/members/SubagentRecentMessagesPreview';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { formatDuration } from '@renderer/utils/formatters';
|
||||
import {
|
||||
|
|
@ -14,6 +18,9 @@ import {
|
|||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
|
||||
import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
|
||||
|
||||
import type { EnhancedChunk } from '@renderer/types/data';
|
||||
import type { MemberLogSummary } from '@shared/types';
|
||||
|
||||
|
|
@ -28,6 +35,10 @@ interface MemberLogsTabProps {
|
|||
taskWorkIntervals?: { startedAt: string; completedAt?: string }[];
|
||||
/** Notifies parent when a background refresh starts/ends. */
|
||||
onRefreshingChange?: (isRefreshing: boolean) => void;
|
||||
/** Show last few subagent messages as a quick "where are we?" preview (task view only). */
|
||||
showSubagentPreview?: boolean;
|
||||
/** Notifies parent when preview looks "online" (recent output). */
|
||||
onPreviewOnlineChange?: (isOnline: boolean) => void;
|
||||
}
|
||||
|
||||
export const MemberLogsTab = ({
|
||||
|
|
@ -38,6 +49,8 @@ export const MemberLogsTab = ({
|
|||
taskStatus,
|
||||
taskWorkIntervals,
|
||||
onRefreshingChange,
|
||||
showSubagentPreview = false,
|
||||
onPreviewOnlineChange,
|
||||
}: MemberLogsTabProps): React.JSX.Element => {
|
||||
const intervalsKey = useMemo(
|
||||
() => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''),
|
||||
|
|
@ -52,6 +65,7 @@ export const MemberLogsTab = ({
|
|||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [detailChunks, setDetailChunks] = useState<EnhancedChunk[] | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [previewChunks, setPreviewChunks] = useState<EnhancedChunk[] | null>(null);
|
||||
|
||||
const getRowId = useCallback((log: MemberLogSummary): string => {
|
||||
return log.kind === 'subagent'
|
||||
|
|
@ -82,6 +96,35 @@ export const MemberLogsTab = ({
|
|||
return withIndex.map((x) => x.log);
|
||||
}, [logs]);
|
||||
|
||||
const previewLog = useMemo((): MemberLogSummary | null => {
|
||||
if (!showSubagentPreview || taskId == null) return null;
|
||||
|
||||
const candidates = sortedLogs.filter((l) => l.kind === 'subagent');
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
if (taskOwner) {
|
||||
const target = taskOwner.trim().toLowerCase();
|
||||
const match = candidates.find((l) => (l.memberName ?? '').trim().toLowerCase() === target);
|
||||
// When viewing task logs, this preview is intended to show the assigned owner's progress.
|
||||
// If we can't confidently match a subagent log to the owner, don't show anything
|
||||
// rather than risk showing a different member's activity (or a lead-attributed log).
|
||||
return match ?? null;
|
||||
}
|
||||
|
||||
return candidates[0] ?? null;
|
||||
}, [showSubagentPreview, sortedLogs, taskId, taskOwner]);
|
||||
|
||||
const previewMessages = useMemo((): SubagentPreviewMessage[] => {
|
||||
if (!previewChunks || previewChunks.length === 0) return [];
|
||||
return extractSubagentPreviewMessages(previewChunks, 4);
|
||||
}, [previewChunks]);
|
||||
|
||||
const previewOnline = useMemo((): boolean => {
|
||||
const newest = previewMessages[0];
|
||||
if (!newest) return false;
|
||||
return Date.now() - newest.timestamp.getTime() <= 10_000;
|
||||
}, [previewMessages]);
|
||||
|
||||
const expandedLogSummary = useMemo(() => {
|
||||
if (!expandedId) return null;
|
||||
return logs.find((log) => getRowId(log) === expandedId) ?? null;
|
||||
|
|
@ -92,6 +135,14 @@ export const MemberLogsTab = ({
|
|||
return () => onRefreshingChange?.(false);
|
||||
}, [refreshing, onRefreshingChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onPreviewOnlineChange?.(previewOnline);
|
||||
}, [onPreviewOnlineChange, previewOnline]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => onPreviewOnlineChange?.(false);
|
||||
}, [onPreviewOnlineChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expandedId) return;
|
||||
if (expandedLogSummary) return;
|
||||
|
|
@ -152,14 +203,68 @@ export const MemberLogsTab = ({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey drives refresh; deps intentionally minimal to avoid refetch loops
|
||||
}, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey]);
|
||||
|
||||
const fetchDetailForLog = useCallback(async (log: MemberLogSummary): Promise<EnhancedChunk[] | null> => {
|
||||
if (log.kind === 'subagent') {
|
||||
const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId);
|
||||
return (d?.chunks ?? null) as EnhancedChunk[] | null;
|
||||
const fetchDetailForLog = useCallback(
|
||||
async (log: MemberLogSummary): Promise<EnhancedChunk[] | null> => {
|
||||
if (log.kind === 'subagent') {
|
||||
const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId);
|
||||
return (d?.chunks ?? null) as EnhancedChunk[] | null;
|
||||
}
|
||||
const d = await api.getSessionDetail(log.projectId, log.sessionId);
|
||||
return (d?.chunks ?? null) as unknown as EnhancedChunk[] | null;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSubagentPreview || taskId == null) {
|
||||
setPreviewChunks(null);
|
||||
return;
|
||||
}
|
||||
const d = await api.getSessionDetail(log.projectId, log.sessionId);
|
||||
return (d?.chunks ?? null) as unknown as EnhancedChunk[] | null;
|
||||
}, []);
|
||||
if (!previewLog) {
|
||||
setPreviewChunks(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const run = async (): Promise<void> => {
|
||||
try {
|
||||
const next = await fetchDetailForLog(previewLog);
|
||||
if (cancelled) return;
|
||||
setPreviewChunks(next ? [...next] : null);
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setPreviewChunks(null);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fetchDetailForLog, previewLog, showSubagentPreview, taskId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSubagentPreview || taskId == null) return;
|
||||
if (!previewLog) return;
|
||||
|
||||
const shouldAutoRefreshPreview = taskStatus === 'in_progress' || previewLog.isOngoing;
|
||||
if (!shouldAutoRefreshPreview) return;
|
||||
|
||||
let cancelled = false;
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const next = await fetchDetailForLog(previewLog);
|
||||
if (cancelled) return;
|
||||
setPreviewChunks(next ? [...next] : null);
|
||||
} catch {
|
||||
// keep last successful preview
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [fetchDetailForLog, previewLog, showSubagentPreview, taskId, taskStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
|
||||
|
|
@ -245,17 +350,19 @@ export const MemberLogsTab = ({
|
|||
|
||||
return (
|
||||
<div className="w-full min-w-0 space-y-1.5">
|
||||
{showSubagentPreview && previewLog && previewMessages.length > 0 ? (
|
||||
<SubagentRecentMessagesPreview
|
||||
messages={previewMessages}
|
||||
memberName={previewLog.memberName ?? undefined}
|
||||
/>
|
||||
) : null}
|
||||
{sortedLogs.map((log) => (
|
||||
<LogCard
|
||||
key={getRowId(log)}
|
||||
log={log}
|
||||
expanded={expandedId === getRowId(log)}
|
||||
detailChunks={
|
||||
expandedId === getRowId(log) ? detailChunks : null
|
||||
}
|
||||
detailLoading={
|
||||
expandedId === getRowId(log) && detailLoading
|
||||
}
|
||||
detailChunks={expandedId === getRowId(log) ? detailChunks : null}
|
||||
detailLoading={expandedId === getRowId(log) && detailLoading}
|
||||
onToggle={() => void handleExpand(log)}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -358,3 +465,54 @@ function formatRelativeTime(isoString: string): string {
|
|||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function extractSubagentPreviewMessages(
|
||||
chunks: EnhancedChunk[],
|
||||
limit: number
|
||||
): SubagentPreviewMessage[] {
|
||||
const conversation = transformChunksToConversation(chunks, [], false);
|
||||
|
||||
const out: SubagentPreviewMessage[] = [];
|
||||
|
||||
// Collect newest-first and stop as soon as we have enough.
|
||||
for (let i = conversation.items.length - 1; i >= 0 && out.length < limit; i--) {
|
||||
const item = conversation.items[i];
|
||||
if (item.type === 'ai') {
|
||||
const enhanced = enhanceAIGroup(item.group);
|
||||
const items = enhanced.displayItems ?? [];
|
||||
for (let j = items.length - 1; j >= 0 && out.length < limit; j--) {
|
||||
const di = items[j];
|
||||
if (di.type === 'output' && di.content.trim()) {
|
||||
out.push({
|
||||
id: `${item.group.id}:output:${di.timestamp.toISOString()}:${j}`,
|
||||
timestamp: di.timestamp,
|
||||
kind: 'output',
|
||||
label: 'Output',
|
||||
content: di.content,
|
||||
});
|
||||
} else if (di.type === 'teammate_message') {
|
||||
out.push({
|
||||
id: `${item.group.id}:teammate:${di.teammateMessage.id}`,
|
||||
timestamp: di.teammateMessage.timestamp,
|
||||
kind: 'teammate_message',
|
||||
label: `Message — ${di.teammateMessage.teammateId}`,
|
||||
content: di.teammateMessage.content || di.teammateMessage.summary,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (item.type === 'user') {
|
||||
const text = item.group.content.rawText ?? item.group.content.text ?? '';
|
||||
if (text.trim()) {
|
||||
out.push({
|
||||
id: `${item.group.id}:user:${item.group.timestamp.toISOString()}`,
|
||||
timestamp: item.group.timestamp,
|
||||
kind: 'user',
|
||||
label: 'User',
|
||||
content: text,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
export type SubagentPreviewMessageKind =
|
||||
| 'output'
|
||||
| 'text'
|
||||
| 'tool_result'
|
||||
| 'interruption'
|
||||
| 'plan_exit'
|
||||
| 'teammate_message'
|
||||
| 'user'
|
||||
| 'unknown';
|
||||
|
||||
export interface SubagentPreviewMessage {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
kind: SubagentPreviewMessageKind;
|
||||
/** Optional short label (e.g. tool name). */
|
||||
label?: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface SubagentRecentMessagesPreviewProps {
|
||||
messages: SubagentPreviewMessage[];
|
||||
memberName?: string;
|
||||
}
|
||||
|
||||
export const SubagentRecentMessagesPreview = ({
|
||||
messages,
|
||||
memberName,
|
||||
}: SubagentRecentMessagesPreviewProps): React.JSX.Element | null => {
|
||||
if (!messages.length) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-3 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 truncate text-[11px] text-[var(--color-text-muted)]">
|
||||
Latest messages{memberName ? ` — ${memberName}` : ''}
|
||||
</div>
|
||||
<div className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||
{format(messages[0].timestamp, 'h:mm:ss a')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{messages.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="rounded border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-2"
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 truncate text-[10px] text-[var(--color-text-muted)]">
|
||||
{m.label ? (
|
||||
<span className="rounded bg-[var(--color-surface)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--color-text-secondary)]">
|
||||
{m.label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono">{m.kind}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||
{format(m.timestamp, 'h:mm:ss a')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{m.kind === 'tool_result' ? (
|
||||
<pre className="max-h-40 overflow-y-auto whitespace-pre-wrap break-words font-mono text-[11px] text-[var(--color-text)]">
|
||||
{m.content}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="max-h-40 overflow-y-auto text-xs text-[var(--color-text)]">
|
||||
<MarkdownViewer content={m.content} copyable />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -13,7 +13,6 @@ import { cn } from '@renderer/lib/utils';
|
|||
import { useStore } from '@renderer/store';
|
||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react';
|
||||
|
||||
|
|
@ -49,7 +48,10 @@ const ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => {
|
|||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="relative flex shrink-0 cursor-default items-center justify-center" style={{ width: size, height: size }}>
|
||||
<div
|
||||
className="relative flex shrink-0 cursor-default items-center justify-center"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle
|
||||
cx={size / 2}
|
||||
|
|
@ -189,7 +191,7 @@ export const MessageComposer = ({
|
|||
|
||||
const handleKeyDownCapture = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSend();
|
||||
|
|
@ -290,7 +292,10 @@ export const MessageComposer = ({
|
|||
>
|
||||
{members.length > 5 && (
|
||||
<div className="relative mb-1">
|
||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]" />
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
||||
/>
|
||||
<input
|
||||
ref={recipientSearchRef}
|
||||
type="text"
|
||||
|
|
@ -392,7 +397,9 @@ export const MessageComposer = ({
|
|||
) : null}
|
||||
|
||||
{!isTeamAlive ? (
|
||||
<span className="ml-auto text-[10px]" style={{ color: 'var(--warning-text)' }}>Team offline</span>
|
||||
<span className="ml-auto text-[10px]" style={{ color: 'var(--warning-text)' }}>
|
||||
Team offline
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
|
|
@ -406,7 +413,7 @@ export const MessageComposer = ({
|
|||
|
||||
<MentionableTextarea
|
||||
id={`compose-${teamName}`}
|
||||
placeholder={`Write a message... (${getModifierKeyName()}+Enter to send)`}
|
||||
placeholder="Write a message... (Enter to send)"
|
||||
value={draft.value}
|
||||
onValueChange={draft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ interface MentionableTextareaProps extends Omit<
|
|||
projectPath?: string | null;
|
||||
/** Called when a file chip is created via @ selection. Parent must add chip to state. */
|
||||
onFileChipInsert?: (chip: InlineChip) => void;
|
||||
/** Called when Cmd+Enter (Mac) / Ctrl+Enter (Win/Linux) is pressed. */
|
||||
/** Called when Enter (without Shift) is pressed. */
|
||||
onModEnter?: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -503,8 +503,8 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
// Composed key handler: Mod+Enter submit → chip logic → mention logic
|
||||
const composedHandleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Mod+Enter (Cmd on Mac, Ctrl on Win/Linux) → submit
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && onModEnter) {
|
||||
// Enter (without Shift) → submit; Shift+Enter → newline
|
||||
if (e.key === 'Enter' && !e.shiftKey && onModEnter) {
|
||||
e.preventDefault();
|
||||
onModEnter();
|
||||
return;
|
||||
|
|
|
|||
Loading…
Reference in a new issue