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 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(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { 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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue