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:
iliya 2026-03-05 21:02:56 +02:00
parent 6a67838d20
commit 218189b241
10 changed files with 627 additions and 247 deletions

View file

@ -62,7 +62,7 @@ const UI_LOGS_TAIL_LIMIT = 128 * 1024;
const SHELL_ENV_TIMEOUT_MS = 12000; const SHELL_ENV_TIMEOUT_MS = 12000;
// const CLI_PREPARE_TIMEOUT_MS = 10000; // const CLI_PREPARE_TIMEOUT_MS = 10000;
const PROBE_CACHE_TTL_MS = 10 * 60_000; 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_RETRY_DELAY_MS = 2000;
const PREFLIGHT_AUTH_MAX_RETRIES = 2; const PREFLIGHT_AUTH_MAX_RETRIES = 2;
const FS_MONITOR_POLL_MS = 2000; 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_JSON_READ_TIMEOUT_MS = 5_000;
const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024; const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024;
const TEAM_INBOX_MAX_BYTES = 2 * 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_PROMPT = 'Output only the single word PONG.';
const PREFLIGHT_PING_ARGS = ['-p', PREFLIGHT_PING_PROMPT, '--output-format', 'text'] as const; 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'; const PREFLIGHT_EXPECTED = 'PONG';
type TeamsBaseLocation = 'configured' | 'default'; type TeamsBaseLocation = 'configured' | 'default';
@ -1008,8 +1020,23 @@ interface CachedProbeResult {
} }
let cachedProbeResult: CachedProbeResult | null = null; let cachedProbeResult: CachedProbeResult | null = null;
let probeInFlight: Promise<{ claudePath: string; authSource: ProvisioningAuthSource; warning?: string } | null> | null = let probeInFlight: Promise<{
null; 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 { export class TeamProvisioningService {
private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000; private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000;
@ -1046,7 +1073,9 @@ export class TeamProvisioningService {
const offsetRaw = query?.offset ?? 0; const offsetRaw = query?.offset ?? 0;
const limitRaw = query?.limit ?? 100; const limitRaw = query?.limit ?? 100;
const offset = Number.isFinite(offsetRaw) ? Math.max(0, Math.floor(offsetRaw)) : 0; 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; const total = run.claudeLogLines.length;
if (total === 0) { if (total === 0) {
@ -1057,8 +1086,10 @@ export class TeamProvisioningService {
const oldestInclusive = Math.max(0, newestExclusive - limit); const oldestInclusive = Math.max(0, newestExclusive - limit);
const normalizeLine = (line: string): string => { const normalizeLine = (line: string): string => {
// Back-compat: older builds prefixed every line with "[stdout] " / "[stderr] " // Back-compat: older builds prefixed every line with "[stdout] " / "[stderr] "
if (line.startsWith('[stdout] ') && line !== '[stdout]') return line.slice('[stdout] '.length); if (line.startsWith('[stdout] ') && line !== '[stdout]')
if (line.startsWith('[stderr] ') && line !== '[stderr]') return line.slice('[stderr] '.length); return line.slice('[stdout] '.length);
if (line.startsWith('[stderr] ') && line !== '[stderr]')
return line.slice('[stderr] '.length);
return line; return line;
}; };
@ -1201,7 +1232,8 @@ export class TeamProvisioningService {
async warmup(): Promise<void> { async warmup(): Promise<void> {
try { 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()); const result = await this.getCachedOrProbeResult(process.cwd());
if (!result) return; if (!result) return;
logger.info('CLI warmup completed'); logger.info('CLI warmup completed');
@ -1226,7 +1258,11 @@ export class TeamProvisioningService {
const ready = !warning || authSource !== 'none' || !isAuthFailure; const ready = !warning || authSource !== 'none' || !isAuthFailure;
return { return {
ready, 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, warnings: warnings.length > 0 ? warnings : undefined,
}; };
} }
@ -1267,7 +1303,10 @@ export class TeamProvisioningService {
return { return {
ready: true, 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, warnings: warnings.length > 0 ? warnings : undefined,
}; };
} }
@ -1287,7 +1326,11 @@ export class TeamProvisioningService {
): Promise<{ claudePath: string; authSource: ProvisioningAuthSource; warning?: string } | null> { ): Promise<{ claudePath: string; authSource: ProvisioningAuthSource; warning?: string } | null> {
const cached = this.getFreshCachedProbeResult(); const cached = this.getFreshCachedProbeResult();
if (cached) { if (cached) {
return { claudePath: cached.claudePath, authSource: cached.authSource, warning: cached.warning }; return {
claudePath: cached.claudePath,
authSource: cached.authSource,
warning: cached.warning,
};
} }
if (probeInFlight) { if (probeInFlight) {
@ -1300,12 +1343,20 @@ export class TeamProvisioningService {
const { env, authSource } = await this.buildProvisioningEnv(); const { env, authSource } = await this.buildProvisioningEnv();
const probe = await this.probeClaudeRuntime(claudePath, cwd, env); 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() }; cachedProbeResult = { ...result, cachedAtMs: Date.now() };
} else { } 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; cachedProbeResult = null;
} }
@ -2819,7 +2870,6 @@ export class TeamProvisioningService {
'team-lead'; 'team-lead';
const leadMsg: InboxMessage = { const leadMsg: InboxMessage = {
from: leadName, from: leadName,
to: 'user',
text: cleanText, text: cleanText,
timestamp: nowIso(), timestamp: nowIso(),
read: true, read: true,
@ -2831,7 +2881,10 @@ export class TeamProvisioningService {
run.leadTextPushedInCurrentTurn = true; run.leadTextPushedInCurrentTurn = true;
const now = Date.now(); 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; run.lastLeadTextEmitMs = now;
this.teamChangeEmitter?.({ this.teamChangeEmitter?.({
type: 'inbox', type: 'inbox',
@ -3126,9 +3179,7 @@ export class TeamProvisioningService {
| undefined; | undefined;
const trigger = typeof meta?.trigger === 'string' ? meta.trigger : 'auto'; const trigger = typeof meta?.trigger === 'string' ? meta.trigger : 'auto';
const preTokens = typeof meta?.pre_tokens === 'number' ? meta.pre_tokens : null; const preTokens = typeof meta?.pre_tokens === 'number' ? meta.pre_tokens : null;
const tokenInfo = preTokens const tokenInfo = preTokens ? ` (was ~${(preTokens / 1000).toFixed(0)}k tokens)` : '';
? ` (was ~${(preTokens / 1000).toFixed(0)}k tokens)`
: '';
const compactMsg: InboxMessage = { const compactMsg: InboxMessage = {
from: 'system', from: 'system',
@ -3160,7 +3211,12 @@ export class TeamProvisioningService {
private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise<void> { private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise<void> {
// Guard: must be set synchronously BEFORE any await to prevent // Guard: must be set synchronously BEFORE any await to prevent
// double-invocation from filesystem monitor + stream-json racing. // 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; return;
// Prevent false "ready" when auth failure was printed as assistant text or logs // Prevent false "ready" when auth failure was printed as assistant text or logs
@ -3172,7 +3228,11 @@ export class TeamProvisioningService {
.filter(Boolean) .filter(Boolean)
.join('\n') .join('\n')
.trim(); .trim();
if (preCompleteText && this.hasApiError(preCompleteText) && !this.isAuthFailureWarning(preCompleteText)) { if (
preCompleteText &&
this.hasApiError(preCompleteText) &&
!this.isAuthFailureWarning(preCompleteText)
) {
this.failProvisioningWithApiError(run, preCompleteText); this.failProvisioningWithApiError(run, preCompleteText);
return; return;
} }
@ -3976,7 +4036,9 @@ export class TeamProvisioningService {
if (membersRaw.length > 0) { if (membersRaw.length > 0) {
const teammateNames = membersRaw const teammateNames = membersRaw
.map((m) => (typeof m.name === 'string' ? m.name.trim() : '')) .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 keepName = createCliAutoSuffixNameGuard(teammateNames);
const nextMembers: Record<string, unknown>[] = []; const nextMembers: Record<string, unknown>[] = [];
@ -4015,7 +4077,9 @@ export class TeamProvisioningService {
const activeNames = metaMembers const activeNames = metaMembers
.filter((m) => !m.removedAt) .filter((m) => !m.removedAt)
.map((m) => m.name.trim()) .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 keepName = createCliAutoSuffixNameGuard(activeNames);
const removedFromMeta: string[] = []; const removedFromMeta: string[] = [];
@ -4042,7 +4106,9 @@ export class TeamProvisioningService {
nextMeta nextMeta
.filter((m) => !m.removedAt) .filter((m) => !m.removedAt)
.map((m) => m.name.trim()) .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 { } catch {
@ -4658,7 +4724,13 @@ export class TeamProvisioningService {
[...PREFLIGHT_PING_ARGS], [...PREFLIGHT_PING_ARGS],
cwd, cwd,
env, env,
PREFLIGHT_TIMEOUT_MS PREFLIGHT_TIMEOUT_MS,
{
resolveOnOutputMatch: ({ stdout, stderr }) => {
const combined = `${stdout}\n${stderr}`.trim();
return /\bPONG\b/i.test(combined);
},
}
); );
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(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 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) { if (!isPong) {
return { return {
warning: warning:
@ -4730,7 +4802,15 @@ export class TeamProvisioningService {
args: string[], args: string[],
cwd: string, cwd: string,
env: NodeJS.ProcessEnv, 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 }> { ): Promise<{ exitCode: number | null; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const child = spawnCli(claudePath, args, { const child = spawnCli(claudePath, args, {
@ -4738,26 +4818,52 @@ export class TeamProvisioningService {
env, env,
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}); });
const stdoutChunks: Buffer[] = []; let stdoutText = '';
const stderrChunks: Buffer[] = []; let stderrText = '';
let settled = false;
const timeoutHandle = setTimeout(() => { const timeoutHandle = setTimeout(() => {
settled = true;
killProcessTree(child); killProcessTree(child);
reject(new Error(`Timeout running: claude ${args.join(' ')}`)); reject(new Error(`Timeout running: claude ${args.join(' ')}`));
}, timeoutMs); }, timeoutMs);
child.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk)); const maybeResolveEarly = (): void => {
child.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk)); 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) => { child.once('error', (error) => {
if (settled) return;
settled = true;
clearTimeout(timeoutHandle); clearTimeout(timeoutHandle);
reject(error); reject(error);
}); });
child.once('close', (exitCode) => { child.once('close', (exitCode) => {
if (settled) return;
settled = true;
clearTimeout(timeoutHandle); clearTimeout(timeoutHandle);
resolve({ resolve({
exitCode, exitCode,
stdout: Buffer.concat(stdoutChunks).toString('utf8').trim(), stdout: stdoutText.trim(),
stderr: Buffer.concat(stderrChunks).toString('utf8').trim(), stderr: stderrText.trim(),
}); });
}); });
}); });

View file

@ -3,14 +3,12 @@ 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 { useStore } from '@renderer/store';
import { Search, Terminal, X } 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 { import { ClaudeLogsFilterPopover, DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover';
ClaudeLogsFilterPopover,
DEFAULT_CLAUDE_LOGS_FILTER,
} from './ClaudeLogsFilterPopover';
import type { TeamClaudeLogsResponse } from '@shared/types'; import type { TeamClaudeLogsResponse } from '@shared/types';
import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover'; import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover';
@ -108,7 +106,10 @@ function filterStreamJsonText(
return null; 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)) { if (Array.isArray(parsed.content)) {
return { ...parsed, content: blocks }; return { ...parsed, content: blocks };
} }
@ -188,6 +189,7 @@ function filterStreamJsonText(
} }
export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.JSX.Element => { export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.JSX.Element => {
const isAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false);
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 [pending, setPending] = useState<TeamClaudeLogsResponse | null>(null);
@ -233,12 +235,16 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
useEffect(() => { useEffect(() => {
let cancelled = false; 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; if (committed.lines.length === 0) return latest.lines.length;
const marker = committed.lines[0]; const marker = committed.lines[0];
const idx = latest.lines.indexOf(marker); const idx = latest.lines.indexOf(marker);
if (idx >= 0) return idx; 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); 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{' '} Showing <span className="font-mono">{Math.min(data.total, visibleCount)}</span> of{' '}
<span className="font-mono">{data.total}</span> <span className="font-mono">{data.total}</span>
</> </>
) : ( ) : isAlive ? (
'No logs yet.' 'No logs yet.'
) : (
'Team is not running.'
)} )}
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -366,7 +374,7 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
<Button <Button
variant="outline" variant="outline"
size="sm" 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} onClick={applyPending}
title="Show newest logs" title="Show newest logs"
> >
@ -386,12 +394,7 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
</div> </div>
</div> </div>
<div <div className={cn('rounded', loading && 'opacity-80')}>
className={cn(
'rounded',
loading && 'opacity-80'
)}
>
{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 && filteredText.trim().length > 0 ? ( {!error && filteredText.trim().length > 0 ? (
<CliLogsRichView <CliLogsRichView
@ -414,16 +417,13 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
) : null} ) : null}
{!error && data.lines.length === 0 ? ( {!error && data.lines.length === 0 ? (
<p className="p-2 text-xs text-[var(--color-text-muted)]"> <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> </p>
) : null} ) : null}
{!error && data.lines.length > 0 && filteredText.trim().length === 0 ? ( {!error && data.lines.length > 0 && filteredText.trim().length === 0 ? (
<p className="p-2 text-xs text-[var(--color-text-muted)]"> <p className="p-2 text-xs text-[var(--color-text-muted)]">No matching logs.</p>
No matching logs.
</p>
) : null} ) : null}
</div> </div>
</CollapsibleTeamSection> </CollapsibleTeamSection>
); );
}; };

View file

@ -26,7 +26,6 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { AlertCircle, ImagePlus, Send, X } from 'lucide-react'; import { AlertCircle, ImagePlus, Send, X } from 'lucide-react';
import { MemberBadge } from '../MemberBadge'; import { MemberBadge } from '../MemberBadge';
@ -129,7 +128,16 @@ export const SendMessageDialog = ({
} }
} }
prevOpenRef.current = open; 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) // Track whether auto-close is needed (avoid setState in render)
useEffect(() => { useEffect(() => {
@ -381,7 +389,7 @@ export const SendMessageDialog = ({
<MentionableTextarea <MentionableTextarea
id="smd-message" id="smd-message"
className={quote ? 'rounded-t-none' : undefined} className={quote ? 'rounded-t-none' : undefined}
placeholder={`Write your message... (${getModifierKeyName()}+Enter to send)`} placeholder="Write your message... (Enter to send)"
value={textDraft.value} value={textDraft.value}
onValueChange={textDraft.setValue} onValueChange={textDraft.setValue}
suggestions={mentionSuggestions} suggestions={mentionSuggestions}
@ -421,7 +429,9 @@ export const SendMessageDialog = ({
</span> </span>
) : null} ) : null}
{textDraft.isSaved ? ( {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} ) : null}
</div> </div>
} }
@ -442,7 +452,6 @@ export const SendMessageDialog = ({
Shown as notification preview. Team lead also sees this for peer messages. Shown as notification preview. Team lead also sees this for peer messages.
</p> </p>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>

View file

@ -7,7 +7,6 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react'; import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react';
@ -71,50 +70,45 @@ export const TaskCommentInput = ({
trimmed.length <= MAX_COMMENT_LENGTH && trimmed.length <= MAX_COMMENT_LENGTH &&
!addingComment; !addingComment;
const addFiles = useCallback( const addFiles = useCallback((files: FileList | File[]) => {
(files: FileList | File[]) => { setAttachError(null);
setAttachError(null); const fileArray = Array.from(files);
const fileArray = Array.from(files); for (const file of fileArray) {
for (const file of fileArray) { if (!ACCEPTED_TYPES.has(file.type)) {
if (!ACCEPTED_TYPES.has(file.type)) { setAttachError(`Unsupported type: ${file.type}`);
setAttachError(`Unsupported type: ${file.type}`); continue;
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);
} }
}, 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) => { const removeAttachment = useCallback((id: string) => {
setPendingAttachments((prev) => prev.filter((a) => a.id !== id)); setPendingAttachments((prev) => prev.filter((a) => a.id !== id));
@ -250,7 +244,7 @@ export const TaskCommentInput = ({
/> />
<MentionableTextarea <MentionableTextarea
id={`task-comment-${taskId}`} id={`task-comment-${taskId}`}
placeholder={`Add a comment... (${getModifierKeyName()}+Enter to send)`} placeholder="Add a comment... (Enter to send)"
value={draft.value} value={draft.value}
onValueChange={draft.setValue} onValueChange={draft.setValue}
suggestions={mentionSuggestions} suggestions={mentionSuggestions}

View file

@ -13,7 +13,6 @@ import { useStore } from '@renderer/store';
import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting'; import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { isImageMimeType } from '@renderer/utils/attachmentUtils'; import { isImageMimeType } from '@renderer/utils/attachmentUtils';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
@ -169,125 +168,128 @@ export const TaskCommentsSection = ({
) : null} ) : null}
<div className={containerClassName ?? ''}> <div className={containerClassName ?? ''}>
{visibleComments.map((comment, index) => ( {visibleComments.map((comment, index) => (
<div <div
key={comment.id} key={comment.id}
className={[ className={[
'group px-4 py-2.5', 'group px-4 py-2.5',
comment.type === 'review_approved' comment.type === 'review_approved'
? 'border-y border-emerald-500/20 bg-emerald-500/5' ? 'border-y border-emerald-500/20 bg-emerald-500/5'
: comment.type === 'review_request' : comment.type === 'review_request'
? 'border-y border-blue-500/20 bg-blue-500/5' ? 'border-y border-blue-500/20 bg-blue-500/5'
: '', : '',
].join(' ')} ].join(' ')}
style={ style={
!comment.type || comment.type === 'regular' !comment.type || comment.type === 'regular'
? { backgroundColor: index % 2 === 1 ? 'var(--card-bg-zebra)' : 'var(--card-bg)' } ? {
: undefined backgroundColor:
} index % 2 === 1 ? 'var(--card-bg-zebra)' : 'var(--card-bg)',
> }
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]"> : undefined
<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"> <div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
<CheckCircle2 size={10} /> <MemberBadge name={comment.author} color={colorMap.get(comment.author)} />
Approved {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> </span>
) : comment.type === 'review_request' ? ( <Tooltip>
<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"> <TooltipTrigger asChild>
<Eye size={10} /> <button
Review requested type="button"
</span> 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"
) : null} onClick={() => {
<span> const replyText = stripAgentBlocks(
{(() => { parseMessageReply(comment.text)?.replyText ?? comment.text
const date = new Date(comment.createdAt); );
return isNaN(date.getTime()) if (onReply) {
? 'unknown time' onReply(comment.author, replyText);
: formatDistanceToNow(date, { addSuffix: true }); } else {
})()} setReplyTo({ author: comment.author, text: replyText });
</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),
}} }}
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 <Reply size={11} />
content={(() => { Reply
let t = linkifyTaskIdsInMarkdown(displayText); </button>
if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap); </TooltipTrigger>
return t; <TooltipContent side="left">Reply to comment</TooltipContent>
})()} </Tooltip>
maxHeight="max-h-none" </div>
bare {(() => {
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> ) : (
)} <span
</ExpandableContent> onClickCapture={
); onTaskIdClick
})()} ? (e) => {
{comment.attachments && comment.attachments.length > 0 ? ( const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
<CommentAttachments 'a[href^="task://"]'
attachments={comment.attachments} );
teamName={teamName} if (link) {
taskId={taskId} e.preventDefault();
onPreview={setPreviewImageUrl} e.stopPropagation();
/> const id = link.getAttribute('href')?.replace('task://', '');
) : null} if (id) onTaskIdClick(id);
</div> }
))} }
: 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> </div>
{sortedComments.length > visibleComments.length ? ( {sortedComments.length > visibleComments.length ? (
@ -347,7 +349,7 @@ export const TaskCommentsSection = ({
<div className="relative"> <div className="relative">
<MentionableTextarea <MentionableTextarea
id={`task-comment-${taskId}`} id={`task-comment-${taskId}`}
placeholder={`Add a comment... (${getModifierKeyName()}+Enter to send)`} placeholder="Add a comment... (Enter to send)"
value={draft.value} value={draft.value}
onValueChange={draft.setValue} onValueChange={draft.setValue}
suggestions={mentionSuggestions} suggestions={mentionSuggestions}

View file

@ -100,6 +100,7 @@ export const TaskDetailDialog = ({
const updateTaskFields = useStore((s) => s.updateTaskFields); const updateTaskFields = useStore((s) => s.updateTaskFields);
const [logsRefreshing, setLogsRefreshing] = useState(false); const [logsRefreshing, setLogsRefreshing] = useState(false);
const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false);
// Inline editing: subject // Inline editing: subject
const [editingSubject, setEditingSubject] = useState(false); const [editingSubject, setEditingSubject] = useState(false);
@ -289,6 +290,11 @@ export const TaskDetailDialog = ({
.map((t) => t.id); .map((t) => t.id);
const isTodo = status === 'pending' && !kanbanColumn; const isTodo = status === 'pending' && !kanbanColumn;
const canReassign = isTodo && onOwnerChange; 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 ( return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}> <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
@ -647,10 +653,23 @@ export const TaskDetailDialog = ({
title="Execution Logs" title="Execution Logs"
icon={<ScrollText size={14} />} icon={<ScrollText size={14} />}
headerExtra={ headerExtra={
logsRefreshing ? ( logsRefreshing || executionPreviewOnline ? (
<span className="flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]"> <span className="flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
<Loader2 size={10} className="animate-spin" /> {executionPreviewOnline ? (
Updating... <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> </span>
) : null ) : null
} }
@ -667,6 +686,11 @@ export const TaskDetailDialog = ({
taskStatus={currentTask.status} taskStatus={currentTask.status}
taskWorkIntervals={currentTask.workIntervals} taskWorkIntervals={currentTask.workIntervals}
onRefreshingChange={setLogsRefreshing} 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> </div>
</CollapsibleTeamSection> </CollapsibleTeamSection>

View file

@ -2,6 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; 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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { formatDuration } from '@renderer/utils/formatters'; import { formatDuration } from '@renderer/utils/formatters';
import { import {
@ -14,6 +18,9 @@ import {
MessageSquare, MessageSquare,
} from 'lucide-react'; } from 'lucide-react';
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
import type { EnhancedChunk } from '@renderer/types/data'; import type { EnhancedChunk } from '@renderer/types/data';
import type { MemberLogSummary } from '@shared/types'; import type { MemberLogSummary } from '@shared/types';
@ -28,6 +35,10 @@ interface MemberLogsTabProps {
taskWorkIntervals?: { startedAt: string; completedAt?: string }[]; taskWorkIntervals?: { startedAt: string; completedAt?: string }[];
/** Notifies parent when a background refresh starts/ends. */ /** Notifies parent when a background refresh starts/ends. */
onRefreshingChange?: (isRefreshing: boolean) => void; 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 = ({ export const MemberLogsTab = ({
@ -38,6 +49,8 @@ export const MemberLogsTab = ({
taskStatus, taskStatus,
taskWorkIntervals, taskWorkIntervals,
onRefreshingChange, onRefreshingChange,
showSubagentPreview = false,
onPreviewOnlineChange,
}: MemberLogsTabProps): React.JSX.Element => { }: MemberLogsTabProps): React.JSX.Element => {
const intervalsKey = useMemo( const intervalsKey = useMemo(
() => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''), () => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''),
@ -52,6 +65,7 @@ export const MemberLogsTab = ({
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const [detailChunks, setDetailChunks] = useState<EnhancedChunk[] | null>(null); const [detailChunks, setDetailChunks] = useState<EnhancedChunk[] | null>(null);
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
const [previewChunks, setPreviewChunks] = useState<EnhancedChunk[] | null>(null);
const getRowId = useCallback((log: MemberLogSummary): string => { const getRowId = useCallback((log: MemberLogSummary): string => {
return log.kind === 'subagent' return log.kind === 'subagent'
@ -82,6 +96,35 @@ export const MemberLogsTab = ({
return withIndex.map((x) => x.log); return withIndex.map((x) => x.log);
}, [logs]); }, [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(() => { const expandedLogSummary = useMemo(() => {
if (!expandedId) return null; if (!expandedId) return null;
return logs.find((log) => getRowId(log) === expandedId) ?? null; return logs.find((log) => getRowId(log) === expandedId) ?? null;
@ -92,6 +135,14 @@ export const MemberLogsTab = ({
return () => onRefreshingChange?.(false); return () => onRefreshingChange?.(false);
}, [refreshing, onRefreshingChange]); }, [refreshing, onRefreshingChange]);
useEffect(() => {
onPreviewOnlineChange?.(previewOnline);
}, [onPreviewOnlineChange, previewOnline]);
useEffect(() => {
return () => onPreviewOnlineChange?.(false);
}, [onPreviewOnlineChange]);
useEffect(() => { useEffect(() => {
if (!expandedId) return; if (!expandedId) return;
if (expandedLogSummary) 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 // eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey drives refresh; deps intentionally minimal to avoid refetch loops
}, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey]); }, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey]);
const fetchDetailForLog = useCallback(async (log: MemberLogSummary): Promise<EnhancedChunk[] | null> => { const fetchDetailForLog = useCallback(
if (log.kind === 'subagent') { async (log: MemberLogSummary): Promise<EnhancedChunk[] | null> => {
const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId); if (log.kind === 'subagent') {
return (d?.chunks ?? null) as EnhancedChunk[] | null; 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); if (!previewLog) {
return (d?.chunks ?? null) as unknown as EnhancedChunk[] | null; 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(() => { useEffect(() => {
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress'; const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
@ -245,17 +350,19 @@ export const MemberLogsTab = ({
return ( return (
<div className="w-full min-w-0 space-y-1.5"> <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) => ( {sortedLogs.map((log) => (
<LogCard <LogCard
key={getRowId(log)} key={getRowId(log)}
log={log} log={log}
expanded={expandedId === getRowId(log)} expanded={expandedId === getRowId(log)}
detailChunks={ detailChunks={expandedId === getRowId(log) ? detailChunks : null}
expandedId === getRowId(log) ? detailChunks : null detailLoading={expandedId === getRowId(log) && detailLoading}
}
detailLoading={
expandedId === getRowId(log) && detailLoading
}
onToggle={() => void handleExpand(log)} onToggle={() => void handleExpand(log)}
/> />
))} ))}
@ -358,3 +465,54 @@ function formatRelativeTime(isoString: string): string {
if (diffDays < 7) return `${diffDays}d ago`; if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString(); 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;
}

View file

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

View file

@ -13,7 +13,6 @@ import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react'; import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react';
@ -49,7 +48,10 @@ const ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => {
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <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"> <svg width={size} height={size} className="-rotate-90">
<circle <circle
cx={size / 2} cx={size / 2}
@ -189,7 +191,7 @@ export const MessageComposer = ({
const handleKeyDownCapture = useCallback( const handleKeyDownCapture = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
handleSend(); handleSend();
@ -290,7 +292,10 @@ export const MessageComposer = ({
> >
{members.length > 5 && ( {members.length > 5 && (
<div className="relative mb-1"> <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 <input
ref={recipientSearchRef} ref={recipientSearchRef}
type="text" type="text"
@ -392,7 +397,9 @@ export const MessageComposer = ({
) : null} ) : null}
{!isTeamAlive ? ( {!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} ) : null}
</div> </div>
@ -406,7 +413,7 @@ export const MessageComposer = ({
<MentionableTextarea <MentionableTextarea
id={`compose-${teamName}`} id={`compose-${teamName}`}
placeholder={`Write a message... (${getModifierKeyName()}+Enter to send)`} placeholder="Write a message... (Enter to send)"
value={draft.value} value={draft.value}
onValueChange={draft.setValue} onValueChange={draft.setValue}
suggestions={mentionSuggestions} suggestions={mentionSuggestions}

View file

@ -204,7 +204,7 @@ interface MentionableTextareaProps extends Omit<
projectPath?: string | null; projectPath?: string | null;
/** Called when a file chip is created via @ selection. Parent must add chip to state. */ /** Called when a file chip is created via @ selection. Parent must add chip to state. */
onFileChipInsert?: (chip: InlineChip) => void; onFileChipInsert?: (chip: InlineChip) => void;
/** Called when Cmd+Enter (Mac) / Ctrl+Enter (Win/Linux) is pressed. */ /** Called when Enter (without Shift) is pressed. */
onModEnter?: () => void; onModEnter?: () => void;
} }
@ -503,8 +503,8 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
// Composed key handler: Mod+Enter submit → chip logic → mention logic // Composed key handler: Mod+Enter submit → chip logic → mention logic
const composedHandleKeyDown = React.useCallback( const composedHandleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => { (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Mod+Enter (Cmd on Mac, Ctrl on Win/Linux) → submit // Enter (without Shift) → submit; Shift+Enter → newline
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && onModEnter) { if (e.key === 'Enter' && !e.shiftKey && onModEnter) {
e.preventDefault(); e.preventDefault();
onModEnter(); onModEnter();
return; return;