feat: add Claude logs retrieval functionality
- Introduced TEAM_GET_CLAUDE_LOGS IPC channel for fetching buffered Claude CLI logs. - Implemented handleGetClaudeLogs function to validate requests and retrieve logs with pagination support. - Enhanced TeamProvisioningService to manage and store Claude log lines, including limits on stored logs. - Added ClaudeLogsSection component to display logs in the UI, with support for pagination and real-time updates. - Updated relevant types and API interfaces to accommodate new log retrieval features.
This commit is contained in:
parent
82bea01e0f
commit
fdb52922fe
17 changed files with 553 additions and 87 deletions
|
|
@ -14,6 +14,7 @@ import {
|
|||
TEAM_DELETE_TEAM,
|
||||
TEAM_GET_ALL_TASKS,
|
||||
TEAM_GET_ATTACHMENTS,
|
||||
TEAM_GET_CLAUDE_LOGS,
|
||||
TEAM_GET_DATA,
|
||||
TEAM_GET_DELETED_TASKS,
|
||||
TEAM_GET_LOGS_FOR_TASK,
|
||||
|
|
@ -108,6 +109,8 @@ import type {
|
|||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
TeamData,
|
||||
TeamClaudeLogsQuery,
|
||||
TeamClaudeLogsResponse,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamMessageNotificationData,
|
||||
|
|
@ -195,6 +198,7 @@ export function initializeTeamHandlers(
|
|||
export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.handle(TEAM_LIST, handleListTeams);
|
||||
ipcMain.handle(TEAM_GET_DATA, handleGetData);
|
||||
ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs);
|
||||
ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning);
|
||||
ipcMain.handle(TEAM_CREATE, handleCreateTeam);
|
||||
ipcMain.handle(TEAM_LAUNCH, handleLaunchTeam);
|
||||
|
|
@ -248,6 +252,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.removeHandler(TEAM_LIST);
|
||||
ipcMain.removeHandler(TEAM_GET_DATA);
|
||||
ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS);
|
||||
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
|
||||
ipcMain.removeHandler(TEAM_CREATE);
|
||||
ipcMain.removeHandler(TEAM_LAUNCH);
|
||||
|
|
@ -634,6 +639,39 @@ async function validateProvisioningRequest(
|
|||
};
|
||||
}
|
||||
|
||||
async function handleGetClaudeLogs(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
query?: unknown
|
||||
): Promise<IpcResult<TeamClaudeLogsResponse>> {
|
||||
const validated = validateTeamName(teamName);
|
||||
if (!validated.valid) {
|
||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
}
|
||||
|
||||
let parsed: TeamClaudeLogsQuery | undefined;
|
||||
if (query !== undefined) {
|
||||
if (!query || typeof query !== 'object') {
|
||||
return { success: false, error: 'query must be an object' };
|
||||
}
|
||||
const q = query as Record<string, unknown>;
|
||||
parsed = {
|
||||
offset: typeof q.offset === 'number' ? q.offset : undefined,
|
||||
limit: typeof q.limit === 'number' ? q.limit : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return wrapTeamHandler('getClaudeLogs', async () => {
|
||||
const data = getTeamProvisioningService().getClaudeLogs(validated.value!, parsed);
|
||||
return {
|
||||
lines: data.lines,
|
||||
total: data.total,
|
||||
hasMore: data.hasMore,
|
||||
updatedAt: data.updatedAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCreateTeam(
|
||||
event: IpcMainInvokeEvent,
|
||||
request: unknown
|
||||
|
|
|
|||
|
|
@ -114,6 +114,14 @@ interface ProvisioningRun {
|
|||
progress: TeamProvisioningProgress;
|
||||
stdoutBuffer: string;
|
||||
stderrBuffer: string;
|
||||
/** Rolling buffer of CLI log lines (oldest -> newest). */
|
||||
claudeLogLines: string[];
|
||||
/** Carry buffer for stdout line splitting (CLI output). */
|
||||
stdoutLogLineBuf: string;
|
||||
/** Carry buffer for stderr line splitting (CLI output). */
|
||||
stderrLogLineBuf: string;
|
||||
/** ISO timestamp when the last CLI line was recorded. */
|
||||
claudeLogsUpdatedAt?: string;
|
||||
processKilled: boolean;
|
||||
finalizingByTimeout: boolean;
|
||||
cancelRequested: boolean;
|
||||
|
|
@ -719,7 +727,8 @@ ${membersFooter}
|
|||
function buildLaunchPrompt(
|
||||
request: TeamLaunchRequest,
|
||||
members: TeamCreateRequest['members'],
|
||||
tasks: TeamTask[]
|
||||
tasks: TeamTask[],
|
||||
isResume: boolean
|
||||
): string {
|
||||
const membersBlock = buildMembersPrompt(members);
|
||||
const userPromptBlock = request.prompt?.trim()
|
||||
|
|
@ -828,7 +837,9 @@ ${memberSpawnInstructions}
|
|||
? `Members:\n${membersBlock}`
|
||||
: 'Members: (none — solo team lead)';
|
||||
|
||||
return `Team Start [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"]
|
||||
const startLabel = isResume ? 'Team Start (resume)' : 'Team Start';
|
||||
|
||||
return `${startLabel} [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"]
|
||||
|
||||
You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
|
||||
You are "${leadName}", the team lead.
|
||||
|
|
@ -967,6 +978,8 @@ interface CachedProbeResult {
|
|||
let cachedProbeResult: CachedProbeResult | null = null;
|
||||
|
||||
export class TeamProvisioningService {
|
||||
private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000;
|
||||
|
||||
private readonly runs = new Map<string, ProvisioningRun>();
|
||||
private readonly activeByTeam = new Map<string, string>();
|
||||
private readonly teamOpLocks = new Map<string, Promise<void>>();
|
||||
|
|
@ -983,6 +996,71 @@ export class TeamProvisioningService {
|
|||
private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore()
|
||||
) {}
|
||||
|
||||
getClaudeLogs(
|
||||
teamName: string,
|
||||
query?: { offset?: number; limit?: number }
|
||||
): { lines: string[]; total: number; hasMore: boolean; updatedAt?: string } {
|
||||
const runId = this.activeByTeam.get(teamName);
|
||||
if (!runId) {
|
||||
return { lines: [], total: 0, hasMore: false };
|
||||
}
|
||||
const run = this.runs.get(runId);
|
||||
if (!run) {
|
||||
return { lines: [], total: 0, hasMore: false };
|
||||
}
|
||||
|
||||
const offsetRaw = query?.offset ?? 0;
|
||||
const limitRaw = query?.limit ?? 100;
|
||||
const offset = Number.isFinite(offsetRaw) ? Math.max(0, Math.floor(offsetRaw)) : 0;
|
||||
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100;
|
||||
|
||||
const total = run.claudeLogLines.length;
|
||||
if (total === 0) {
|
||||
return { lines: [], total: 0, hasMore: false, updatedAt: run.claudeLogsUpdatedAt };
|
||||
}
|
||||
|
||||
const newestExclusive = Math.max(0, total - offset);
|
||||
const oldestInclusive = Math.max(0, newestExclusive - limit);
|
||||
const windowOldestToNewest = run.claudeLogLines.slice(oldestInclusive, newestExclusive);
|
||||
const lines = windowOldestToNewest.reverse();
|
||||
return {
|
||||
lines,
|
||||
total,
|
||||
hasMore: oldestInclusive > 0,
|
||||
updatedAt: run.claudeLogsUpdatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private appendCliLogs(run: ProvisioningRun, stream: 'stdout' | 'stderr', text: string): void {
|
||||
const nowMs = Date.now();
|
||||
run.claudeLogsUpdatedAt = new Date(nowMs).toISOString();
|
||||
|
||||
const prefix = stream === 'stdout' ? '[stdout] ' : '[stderr] ';
|
||||
if (stream === 'stdout') {
|
||||
run.stdoutLogLineBuf += text;
|
||||
const parts = run.stdoutLogLineBuf.split('\n');
|
||||
run.stdoutLogLineBuf = parts.pop() ?? '';
|
||||
for (const part of parts) {
|
||||
const normalized = part.endsWith('\r') ? part.slice(0, -1) : part;
|
||||
run.claudeLogLines.push(prefix + normalized);
|
||||
}
|
||||
} else {
|
||||
run.stderrLogLineBuf += text;
|
||||
const parts = run.stderrLogLineBuf.split('\n');
|
||||
run.stderrLogLineBuf = parts.pop() ?? '';
|
||||
for (const part of parts) {
|
||||
const normalized = part.endsWith('\r') ? part.slice(0, -1) : part;
|
||||
run.claudeLogLines.push(prefix + normalized);
|
||||
}
|
||||
}
|
||||
if (run.claudeLogLines.length > TeamProvisioningService.CLAUDE_LOG_LINES_LIMIT) {
|
||||
run.claudeLogLines.splice(
|
||||
0,
|
||||
run.claudeLogLines.length - TeamProvisioningService.CLAUDE_LOG_LINES_LIMIT
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes operations per team name using promise-chaining.
|
||||
* Same pattern as withInboxLock / withTaskLock.
|
||||
|
|
@ -1189,6 +1267,67 @@ export class TeamProvisioningService {
|
|||
);
|
||||
}
|
||||
|
||||
private hasApiError(text: string): boolean {
|
||||
return /api error:\s*\d{3}\b/i.test(text) || /invalid_request_error/i.test(text);
|
||||
}
|
||||
|
||||
private sanitizeCliSnippet(text: string): string {
|
||||
// Remove control characters that often show up as binary noise in CLI error payloads.
|
||||
// Preserve newlines/tabs for readability.
|
||||
return text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '');
|
||||
}
|
||||
|
||||
private extractApiErrorSnippet(text: string): string | null {
|
||||
const match = /api error:\s*\d{3}\b/i.exec(text) ?? /invalid_request_error/i.exec(text);
|
||||
if (!match || match.index === undefined) return null;
|
||||
const start = Math.max(0, match.index - 200);
|
||||
const end = Math.min(text.length, match.index + 4000);
|
||||
const raw = text.slice(start, end).trim();
|
||||
if (!raw) return null;
|
||||
// Avoid breaking markdown fences if the payload contains ``` accidentally.
|
||||
return this.sanitizeCliSnippet(raw).replace(/```/g, '``\\`');
|
||||
}
|
||||
|
||||
private failProvisioningWithApiError(run: ProvisioningRun, source: string): void {
|
||||
if (run.provisioningComplete || run.processKilled || run.authRetryInProgress) return;
|
||||
if (run.progress.state === 'failed' || run.cancelRequested) return;
|
||||
|
||||
const combined = [
|
||||
buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer),
|
||||
run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('\n') : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
const snippet =
|
||||
this.extractApiErrorSnippet(combined) ?? this.extractApiErrorSnippet(source) ?? null;
|
||||
const status =
|
||||
/api error:\s*(\d{3})\b/i.exec(combined)?.[1] ?? /api error:\s*(\d{3})\b/i.exec(source)?.[1];
|
||||
|
||||
const hint = run.isLaunch ? 'Launch' : 'Provisioning';
|
||||
const statusLabel = status ? `API Error ${status}` : 'API Error';
|
||||
if (snippet) {
|
||||
run.provisioningOutputParts.push(
|
||||
`**${hint} failed: ${statusLabel} detected**\n\n\`\`\`\n${snippet}\n\`\`\``
|
||||
);
|
||||
} else {
|
||||
run.provisioningOutputParts.push(`**${hint} failed: ${statusLabel} detected**`);
|
||||
}
|
||||
|
||||
const progress = updateProgress(run, 'failed', `${hint} failed — ${statusLabel}`, {
|
||||
error: `Claude CLI reported ${statusLabel} during startup. The team was not started.`,
|
||||
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
|
||||
});
|
||||
run.onProgress(progress);
|
||||
|
||||
run.processKilled = true;
|
||||
run.cancelRequested = true;
|
||||
run.child?.stdin?.end();
|
||||
killProcessTree(run.child);
|
||||
this.cleanupRun(run);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects auth failure keywords in stderr/stdout during provisioning.
|
||||
* On first detection: kills process, waits, and respawns automatically.
|
||||
|
|
@ -1250,6 +1389,10 @@ export class TeamProvisioningService {
|
|||
// Reset buffers for fresh attempt
|
||||
run.stdoutBuffer = '';
|
||||
run.stderrBuffer = '';
|
||||
run.claudeLogLines = [];
|
||||
run.stdoutLogLineBuf = '';
|
||||
run.stderrLogLineBuf = '';
|
||||
run.claudeLogsUpdatedAt = undefined;
|
||||
run.authFailureRetried = true;
|
||||
|
||||
updateProgress(run, 'spawning', 'Auth failed — retrying after short delay');
|
||||
|
|
@ -1362,6 +1505,7 @@ export class TeamProvisioningService {
|
|||
let stdoutLineBuf = '';
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString('utf8');
|
||||
this.appendCliLogs(run, 'stdout', text);
|
||||
run.stdoutBuffer += text;
|
||||
if (run.stdoutBuffer.length > STDOUT_RING_LIMIT) {
|
||||
run.stdoutBuffer = run.stdoutBuffer.slice(run.stdoutBuffer.length - STDOUT_RING_LIMIT);
|
||||
|
|
@ -1380,6 +1524,9 @@ export class TeamProvisioningService {
|
|||
} catch {
|
||||
// Not valid JSON — check for auth failure in raw text output
|
||||
this.handleAuthFailureInOutput(run, trimmed, 'stdout');
|
||||
if (this.hasApiError(trimmed) && !this.isAuthFailureWarning(trimmed)) {
|
||||
this.failProvisioningWithApiError(run, trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1398,6 +1545,7 @@ export class TeamProvisioningService {
|
|||
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString('utf8');
|
||||
this.appendCliLogs(run, 'stderr', text);
|
||||
run.stderrBuffer += text;
|
||||
if (run.stderrBuffer.length > STDERR_RING_LIMIT) {
|
||||
run.stderrBuffer = run.stderrBuffer.slice(run.stderrBuffer.length - STDERR_RING_LIMIT);
|
||||
|
|
@ -1405,6 +1553,9 @@ export class TeamProvisioningService {
|
|||
|
||||
// Detect auth failure early instead of waiting for 5-minute timeout
|
||||
this.handleAuthFailureInOutput(run, text, 'stderr');
|
||||
if (this.hasApiError(text) && !this.isAuthFailureWarning(text)) {
|
||||
this.failProvisioningWithApiError(run, text);
|
||||
}
|
||||
|
||||
const currentTs = Date.now();
|
||||
if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) {
|
||||
|
|
@ -1460,6 +1611,10 @@ export class TeamProvisioningService {
|
|||
startedAt,
|
||||
stdoutBuffer: '',
|
||||
stderrBuffer: '',
|
||||
claudeLogLines: [],
|
||||
stdoutLogLineBuf: '',
|
||||
stderrLogLineBuf: '',
|
||||
claudeLogsUpdatedAt: undefined,
|
||||
processKilled: false,
|
||||
finalizingByTimeout: false,
|
||||
cancelRequested: false,
|
||||
|
|
@ -1744,6 +1899,10 @@ export class TeamProvisioningService {
|
|||
startedAt,
|
||||
stdoutBuffer: '',
|
||||
stderrBuffer: '',
|
||||
claudeLogLines: [],
|
||||
stdoutLogLineBuf: '',
|
||||
stderrLogLineBuf: '',
|
||||
claudeLogsUpdatedAt: undefined,
|
||||
processKilled: false,
|
||||
finalizingByTimeout: false,
|
||||
cancelRequested: false,
|
||||
|
|
@ -1800,7 +1959,12 @@ export class TeamProvisioningService {
|
|||
);
|
||||
}
|
||||
|
||||
const prompt = buildLaunchPrompt(request, expectedMemberSpecs, existingTasks);
|
||||
const prompt = buildLaunchPrompt(
|
||||
request,
|
||||
expectedMemberSpecs,
|
||||
existingTasks,
|
||||
Boolean(previousSessionId)
|
||||
);
|
||||
let child: ReturnType<typeof spawn>;
|
||||
const { env: shellEnv } = await this.buildProvisioningEnv();
|
||||
const launchArgs = [
|
||||
|
|
@ -2480,6 +2644,10 @@ export class TeamProvisioningService {
|
|||
// Auth failures sometimes show up as assistant text (e.g. "401", "Please run /login")
|
||||
// rather than stderr or a result.subtype=error. Detect early to avoid false "ready".
|
||||
this.handleAuthFailureInOutput(run, text, 'assistant');
|
||||
if (this.hasApiError(text) && !this.isAuthFailureWarning(text)) {
|
||||
this.failProvisioningWithApiError(run, text);
|
||||
return;
|
||||
}
|
||||
logger.debug(`[${run.teamName}] assistant: ${text.slice(0, 200)}`);
|
||||
// During provisioning (before provisioningComplete), accumulate for live UI preview.
|
||||
// Emission is handled by the throttled emitLogsProgress() in the stdout data handler.
|
||||
|
|
@ -2735,9 +2903,39 @@ export class TeamProvisioningService {
|
|||
// Handle compact_boundary — context was compacted, next assistant message will carry fresh usage
|
||||
if (msg.type === 'system') {
|
||||
const sub = typeof msg.subtype === 'string' ? msg.subtype : undefined;
|
||||
if (sub === 'compact_boundary' && run.leadContextUsage) {
|
||||
run.leadContextUsage.lastUsageMessageId = null;
|
||||
logger.info(`[${run.teamName}] compact_boundary — context will refresh on next turn`);
|
||||
if (sub === 'compact_boundary') {
|
||||
if (run.leadContextUsage) {
|
||||
run.leadContextUsage.lastUsageMessageId = null;
|
||||
}
|
||||
|
||||
// Extract compact metadata for the system message
|
||||
const meta = (msg as Record<string, unknown>).compact_metadata as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const trigger = typeof meta?.trigger === 'string' ? meta.trigger : 'auto';
|
||||
const preTokens = typeof meta?.pre_tokens === 'number' ? meta.pre_tokens : null;
|
||||
const tokenInfo = preTokens
|
||||
? ` (was ~${(preTokens / 1000).toFixed(0)}k tokens)`
|
||||
: '';
|
||||
|
||||
const compactMsg: InboxMessage = {
|
||||
from: 'system',
|
||||
text: `Context compacted${tokenInfo}, trigger: ${trigger}`,
|
||||
timestamp: nowIso(),
|
||||
read: true,
|
||||
summary: `Context compacted (${trigger})`,
|
||||
messageId: `compact-${run.runId}-${Date.now()}`,
|
||||
source: 'lead_process',
|
||||
};
|
||||
this.pushLiveLeadProcessMessage(run.teamName, compactMsg);
|
||||
this.teamChangeEmitter?.({
|
||||
type: 'inbox',
|
||||
teamName: run.teamName,
|
||||
detail: 'compact_boundary',
|
||||
});
|
||||
logger.info(
|
||||
`[${run.teamName}] compact_boundary — context will refresh on next turn${tokenInfo}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2750,19 +2948,24 @@ export class TeamProvisioningService {
|
|||
private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise<void> {
|
||||
// Guard: must be set synchronously BEFORE any await to prevent
|
||||
// double-invocation from filesystem monitor + stream-json racing.
|
||||
if (run.provisioningComplete || run.cancelRequested) return;
|
||||
if (run.provisioningComplete || run.cancelRequested || run.processKilled || run.progress.state === 'failed')
|
||||
return;
|
||||
|
||||
// Prevent false "ready" when auth failure was printed as assistant text or logs
|
||||
// but the filesystem monitor observed files on disk.
|
||||
const authFailureText = [
|
||||
const preCompleteText = [
|
||||
buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer),
|
||||
run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('\n') : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.trim();
|
||||
if (authFailureText && this.isAuthFailureWarning(authFailureText)) {
|
||||
this.handleAuthFailureInOutput(run, authFailureText, 'pre-complete');
|
||||
if (preCompleteText && this.hasApiError(preCompleteText) && !this.isAuthFailureWarning(preCompleteText)) {
|
||||
this.failProvisioningWithApiError(run, preCompleteText);
|
||||
return;
|
||||
}
|
||||
if (preCompleteText && this.isAuthFailureWarning(preCompleteText)) {
|
||||
this.handleAuthFailureInOutput(run, preCompleteText, 'pre-complete');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -210,6 +210,9 @@ export const TEAM_LIST = 'team:list';
|
|||
/** Get detailed team data */
|
||||
export const TEAM_GET_DATA = 'team:getData';
|
||||
|
||||
/** Get buffered Claude CLI logs (paged, newest-first) */
|
||||
export const TEAM_GET_CLAUDE_LOGS = 'team:getClaudeLogs';
|
||||
|
||||
/** Update team kanban state */
|
||||
export const TEAM_UPDATE_KANBAN = 'team:updateKanban';
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ import {
|
|||
TEAM_DELETE_TEAM,
|
||||
TEAM_GET_ALL_TASKS,
|
||||
TEAM_GET_ATTACHMENTS,
|
||||
TEAM_GET_CLAUDE_LOGS,
|
||||
TEAM_GET_DATA,
|
||||
TEAM_GET_DELETED_TASKS,
|
||||
TEAM_GET_LOGS_FOR_TASK,
|
||||
|
|
@ -197,6 +198,8 @@ import type {
|
|||
TaskChangeSetV2,
|
||||
TaskComment,
|
||||
TeamChangeEvent,
|
||||
TeamClaudeLogsQuery,
|
||||
TeamClaudeLogsResponse,
|
||||
TeamConfig,
|
||||
TeamCreateConfigRequest,
|
||||
TeamCreateRequest,
|
||||
|
|
@ -696,6 +699,9 @@ const electronAPI: ElectronAPI = {
|
|||
getData: async (teamName: string) => {
|
||||
return invokeIpcWithResult<TeamData>(TEAM_GET_DATA, teamName);
|
||||
},
|
||||
getClaudeLogs: async (teamName: string, query?: TeamClaudeLogsQuery) => {
|
||||
return invokeIpcWithResult<TeamClaudeLogsResponse>(TEAM_GET_CLAUDE_LOGS, teamName, query);
|
||||
},
|
||||
deleteTeam: async (teamName: string) => {
|
||||
return invokeIpcWithResult<void>(TEAM_DELETE_TEAM, teamName);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ import type {
|
|||
SshLastConnection,
|
||||
SubagentDetail,
|
||||
TeamChangeEvent,
|
||||
TeamClaudeLogsQuery,
|
||||
TeamClaudeLogsResponse,
|
||||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
TeamData,
|
||||
|
|
@ -644,6 +646,13 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
getData: async (_teamName: string): Promise<TeamData> => {
|
||||
throw new Error('Teams detail is not available in browser mode');
|
||||
},
|
||||
getClaudeLogs: async (
|
||||
_teamName: string,
|
||||
_query?: TeamClaudeLogsQuery
|
||||
): Promise<TeamClaudeLogsResponse> => {
|
||||
console.warn('[HttpAPIClient] getClaudeLogs is not available in browser mode');
|
||||
return { lines: [], total: 0, hasMore: false };
|
||||
},
|
||||
deleteTeam: async (_teamName: string): Promise<void> => {
|
||||
throw new Error('Team deletion is not available in browser mode');
|
||||
},
|
||||
|
|
|
|||
134
src/renderer/components/team/ClaudeLogsSection.tsx
Normal file
134
src/renderer/components/team/ClaudeLogsSection.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { Terminal } from 'lucide-react';
|
||||
|
||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||
|
||||
import type { TeamClaudeLogsResponse } from '@shared/types';
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
const POLL_MS = 2000;
|
||||
const ONLINE_WINDOW_MS = 10_000;
|
||||
|
||||
interface ClaudeLogsSectionProps {
|
||||
teamName: string;
|
||||
}
|
||||
|
||||
function isRecent(updatedAt: string | undefined): boolean {
|
||||
if (!updatedAt) return false;
|
||||
const t = Date.parse(updatedAt);
|
||||
if (Number.isNaN(t)) return false;
|
||||
return Date.now() - t <= ONLINE_WINDOW_MS;
|
||||
}
|
||||
|
||||
export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.JSX.Element => {
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
const [data, setData] = useState<TeamClaudeLogsResponse>({ lines: [], total: 0, hasMore: false });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inFlightRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
setData({ lines: [], total: 0, hasMore: false });
|
||||
setError(null);
|
||||
}, [teamName]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const fetchLogs = async (): Promise<void> => {
|
||||
if (inFlightRef.current) return;
|
||||
inFlightRef.current = true;
|
||||
try {
|
||||
setLoading(true);
|
||||
const next = await api.teams.getClaudeLogs(teamName, { offset: 0, limit: visibleCount });
|
||||
if (cancelled) return;
|
||||
setData(next);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
inFlightRef.current = false;
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchLogs();
|
||||
const id = window.setInterval(() => void fetchLogs(), POLL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [teamName, visibleCount]);
|
||||
|
||||
const online = useMemo(() => isRecent(data.updatedAt), [data.updatedAt]);
|
||||
const badge = data.total > 0 ? data.total : undefined;
|
||||
const showMoreVisible = data.hasMore;
|
||||
|
||||
const headerExtra = online ? (
|
||||
<span className="pointer-events-none relative inline-flex size-2 shrink-0" title="Updating">
|
||||
<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;
|
||||
|
||||
return (
|
||||
<CollapsibleTeamSection
|
||||
sectionId="claude-logs"
|
||||
title="Claude logs"
|
||||
icon={<Terminal size={14} />}
|
||||
badge={badge}
|
||||
headerExtra={headerExtra}
|
||||
defaultOpen
|
||||
contentClassName="pt-0"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 pb-2">
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">
|
||||
{data.total > 0 ? (
|
||||
<>
|
||||
Showing <span className="font-mono">{Math.min(data.total, visibleCount)}</span> of{' '}
|
||||
<span className="font-mono">{data.total}</span>
|
||||
</>
|
||||
) : (
|
||||
'No logs yet.'
|
||||
)}
|
||||
</span>
|
||||
{showMoreVisible && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
|
||||
>
|
||||
Show more
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'max-h-[320px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2',
|
||||
loading && 'opacity-80'
|
||||
)}
|
||||
>
|
||||
{error ? (
|
||||
<p className="text-xs text-red-300">{error}</p>
|
||||
) : data.lines.length > 0 ? (
|
||||
<pre className="whitespace-pre-wrap break-words font-mono text-[11px] leading-4 text-[var(--color-text-secondary)]">
|
||||
{data.lines.join('\n')}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
{loading ? 'Loading…' : 'No logs captured.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTeamSection>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -44,27 +44,66 @@ function formatElapsed(seconds: number): string {
|
|||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function useElapsedTimer(startedAt?: string): string | null {
|
||||
const [elapsed, setElapsed] = useState<string | null>(null);
|
||||
function useElapsedTimer(startedAt?: string, isRunning = true): string | null {
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!startedAt) return () => setElapsed(null);
|
||||
if (!startedAt) {
|
||||
setElapsedSeconds(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const startMs = Date.parse(startedAt);
|
||||
if (isNaN(startMs)) return () => setElapsed(null);
|
||||
if (isNaN(startMs)) {
|
||||
setElapsedSeconds(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const computeElapsedSeconds = (): number =>
|
||||
Math.max(0, Math.floor((Date.now() - startMs) / 1000));
|
||||
|
||||
if (!isRunning) {
|
||||
// Freeze timer on terminal states (failed/ready/cancelled) instead of continuing to tick.
|
||||
setElapsedSeconds((prev) => (prev === null ? computeElapsedSeconds() : prev));
|
||||
return;
|
||||
}
|
||||
|
||||
const tick = (): void => {
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
|
||||
setElapsed(formatElapsed(seconds));
|
||||
setElapsedSeconds(computeElapsedSeconds());
|
||||
};
|
||||
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1000);
|
||||
return () => {
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [startedAt]);
|
||||
}, [startedAt, isRunning]);
|
||||
|
||||
if (!startedAt) return null;
|
||||
return elapsed;
|
||||
if (elapsedSeconds === null) return null;
|
||||
return formatElapsed(elapsedSeconds);
|
||||
}
|
||||
|
||||
function sanitizeAssistantOutput(raw?: string, isError = false): string | null {
|
||||
if (!raw) return null;
|
||||
if (!isError) return raw;
|
||||
|
||||
const looksLikeRawApiEnvelope =
|
||||
raw.includes('API Error: 400') &&
|
||||
(raw.includes('"_requests"') ||
|
||||
raw.includes('"session_id"') ||
|
||||
raw.includes('"parent_tool_use_id"') ||
|
||||
raw.includes('\\u000'));
|
||||
|
||||
if (!looksLikeRawApiEnvelope) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
return (
|
||||
'API Error: 400\n\n' +
|
||||
'Raw payload from CLI stream hidden because it contains encoded/binary-like content.\n\n' +
|
||||
'Open **CLI logs** below for readable diagnostics.'
|
||||
);
|
||||
}
|
||||
|
||||
export const ProvisioningProgressBlock = ({
|
||||
|
|
@ -81,11 +120,12 @@ export const ProvisioningProgressBlock = ({
|
|||
assistantOutput,
|
||||
className,
|
||||
}: ProvisioningProgressBlockProps): React.JSX.Element => {
|
||||
const elapsed = useElapsedTimer(startedAt);
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const elapsed = useElapsedTimer(startedAt, loading);
|
||||
const [logsOpen, setLogsOpen] = useState(() => tone === 'error' && Boolean(cliLogsTail));
|
||||
const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen);
|
||||
const outputScrollRef = useRef<HTMLDivElement>(null);
|
||||
const isError = tone === 'error';
|
||||
const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError);
|
||||
|
||||
// Auto-scroll assistant output
|
||||
useEffect(() => {
|
||||
|
|
@ -99,6 +139,14 @@ export const ProvisioningProgressBlock = ({
|
|||
setLiveOutputOpen(defaultLiveOutputOpen);
|
||||
}, [defaultLiveOutputOpen]);
|
||||
|
||||
// On error with logs available, prioritize logs view over noisy live stream payload.
|
||||
useEffect(() => {
|
||||
if (isError && cliLogsTail) {
|
||||
setLogsOpen(true);
|
||||
setLiveOutputOpen(false);
|
||||
}
|
||||
}, [isError, cliLogsTail]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -190,8 +238,8 @@ export const ProvisioningProgressBlock = ({
|
|||
isError && 'border-red-500/40'
|
||||
)}
|
||||
>
|
||||
{assistantOutput ? (
|
||||
<MarkdownViewer content={assistantOutput} bare maxHeight="max-h-none" />
|
||||
{displayAssistantOutput ? (
|
||||
<MarkdownViewer content={displayAssistantOutput} bare maxHeight="max-h-none" />
|
||||
) : (
|
||||
<p
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ import { MessageComposer } from './messages/MessageComposer';
|
|||
import { MessagesFilterPopover } from './messages/MessagesFilterPopover';
|
||||
import { ChangeReviewDialog } from './review/ChangeReviewDialog';
|
||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||
import { ClaudeLogsSection } from './ClaudeLogsSection';
|
||||
import { ProcessesSection } from './ProcessesSection';
|
||||
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
||||
import { TeamSessionsSection } from './TeamSessionsSection';
|
||||
|
|
@ -1171,6 +1172,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
</CollapsibleTeamSection>
|
||||
)}
|
||||
|
||||
<ClaudeLogsSection teamName={teamName} />
|
||||
|
||||
<CollapsibleTeamSection
|
||||
sectionId="messages"
|
||||
title="Messages"
|
||||
|
|
|
|||
|
|
@ -255,6 +255,21 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
};
|
||||
}, [electronMode, teams]);
|
||||
|
||||
// Refresh alive teams when opening the create dialog so conflict warning is accurate.
|
||||
useEffect(() => {
|
||||
if (!electronMode || !showCreateDialog) return;
|
||||
let cancelled = false;
|
||||
void api.teams
|
||||
.aliveList()
|
||||
.then((list) => {
|
||||
if (!cancelled) setAliveTeams(list);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [electronMode, showCreateDialog]);
|
||||
|
||||
const currentProjectPath = useMemo(() => {
|
||||
if (viewMode === 'grouped') {
|
||||
const repo = repositoryGroups.find((r) => r.id === selectedRepositoryId);
|
||||
|
|
|
|||
|
|
@ -491,10 +491,11 @@ export const CreateTeamDialog = ({
|
|||
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
|
||||
|
||||
const conflictingTeam = useMemo(() => {
|
||||
if (!launchTeam) return null;
|
||||
if (!activeTeams?.length || !effectiveCwd) return null;
|
||||
const norm = normalizePath(effectiveCwd);
|
||||
return activeTeams.find((t) => normalizePath(t.projectPath) === norm) ?? null;
|
||||
}, [activeTeams, effectiveCwd]);
|
||||
}, [activeTeams, effectiveCwd, launchTeam]);
|
||||
|
||||
// Reset dismiss when conflict target changes
|
||||
useEffect(() => {
|
||||
|
|
@ -554,6 +555,18 @@ export const CreateTeamDialog = ({
|
|||
})();
|
||||
};
|
||||
|
||||
const handleTeamNameChange = (value: string): void => {
|
||||
setTeamName(value);
|
||||
setFieldErrors((prev) => {
|
||||
if (!prev.teamName) return prev;
|
||||
const { teamName: _teamName, ...rest } = prev;
|
||||
if (!rest.members && !rest.cwd && localError === 'Check form fields') {
|
||||
setLocalError(null);
|
||||
}
|
||||
return rest;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
|
|
@ -580,13 +593,16 @@ export const CreateTeamDialog = ({
|
|||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="font-medium text-amber-300">
|
||||
Team “{conflictingTeam.displayName}” is already running in this
|
||||
project
|
||||
Another team “{conflictingTeam.displayName}” is already running for
|
||||
this working directory
|
||||
</p>
|
||||
<p className="text-amber-300/80">
|
||||
Running two teams in the same directory is risky — they may conflict editing the
|
||||
same files. Consider using a different directory or a git worktree for isolation.
|
||||
</p>
|
||||
<p className="text-[11px] text-amber-300/70">
|
||||
Working directory: <span className="font-mono">{effectiveCwd}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -641,7 +657,7 @@ export const CreateTeamDialog = ({
|
|||
id="team-name"
|
||||
className="h-8 text-xs"
|
||||
value={teamName}
|
||||
onChange={(event) => setTeamName(event.target.value)}
|
||||
onChange={(event) => handleTeamNameChange(event.target.value)}
|
||||
placeholder="team-alpha"
|
||||
/>
|
||||
{existingTeamNames.includes(sanitizedTeamName) ? (
|
||||
|
|
|
|||
|
|
@ -319,13 +319,16 @@ export const LaunchTeamDialog = ({
|
|||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="font-medium text-amber-300">
|
||||
Team “{conflictingTeam.displayName}” is already running in this
|
||||
project
|
||||
Another team “{conflictingTeam.displayName}” is already running for
|
||||
this working directory
|
||||
</p>
|
||||
<p className="text-amber-300/80">
|
||||
Running two teams in the same directory is risky — they may conflict editing the
|
||||
same files. Consider using a different directory or a git worktree for isolation.
|
||||
</p>
|
||||
<p className="text-[11px] text-amber-300/70">
|
||||
Working directory: <span className="font-mono">{effectiveCwd}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useStore } from '@renderer/store';
|
||||
// import { useStore } from '@renderer/store'; // TODO: disabled — lead context display
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
||||
import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
|
||||
|
|
@ -40,18 +40,13 @@ export const MemberCard = ({
|
|||
onSendMessage,
|
||||
onAssignTask,
|
||||
}: MemberCardProps): React.JSX.Element => {
|
||||
const teamName = useStore((s) => s.selectedTeamName);
|
||||
const leadContext = useStore((s) =>
|
||||
member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
||||
);
|
||||
// TODO: lead context display disabled — usage formula is inaccurate
|
||||
// const teamName = useStore((s) => s.selectedTeamName);
|
||||
// const leadContext = useStore((s) =>
|
||||
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
||||
// );
|
||||
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||
const presenceLabel = getPresenceLabel(
|
||||
member,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
leadActivity,
|
||||
leadContext?.percent
|
||||
);
|
||||
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||
const colors = getTeamColorSet(memberColor);
|
||||
const pending = taskCounts?.pending ?? 0;
|
||||
const inProgress = taskCounts?.inProgress ?? 0;
|
||||
|
|
@ -182,29 +177,7 @@ export const MemberCard = ({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{leadContext && leadContext.percent > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="mx-0.5 mt-0.5 h-[2px] rounded-full bg-[var(--color-border)]">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
leadContext.percent > 90
|
||||
? 'bg-red-500'
|
||||
: leadContext.percent > 70
|
||||
? 'bg-amber-500'
|
||||
: 'bg-blue-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(leadContext.percent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Context: {Math.round(leadContext.percent)}% (
|
||||
{(leadContext.currentTokens / 1000).toFixed(1)}k /{' '}
|
||||
{(leadContext.contextWindow / 1000).toFixed(0)}k tokens)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* TODO: lead context bar disabled — usage formula is inaccurate */}
|
||||
</div>
|
||||
{!isRemoved && (
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useState } from 'react';
|
|||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useStore } from '@renderer/store';
|
||||
// import { useStore } from '@renderer/store'; // TODO: disabled — lead context display
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
||||
import { Pencil } from 'lucide-react';
|
||||
|
|
@ -31,20 +31,15 @@ export const MemberDetailHeader = ({
|
|||
}: MemberDetailHeaderProps): React.JSX.Element => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const teamName = useStore((s) => s.selectedTeamName);
|
||||
const leadContext = useStore((s) =>
|
||||
member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
||||
);
|
||||
// TODO: lead context display disabled — usage formula is inaccurate
|
||||
// const teamName = useStore((s) => s.selectedTeamName);
|
||||
// const leadContext = useStore((s) =>
|
||||
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
||||
// );
|
||||
|
||||
const colors = getTeamColorSet(member.color ?? '');
|
||||
const role = member.role || formatAgentRole(member.agentType);
|
||||
const presenceLabel = getPresenceLabel(
|
||||
member,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
leadActivity,
|
||||
leadContext?.percent
|
||||
);
|
||||
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||
|
||||
const canEditRole =
|
||||
|
|
@ -107,12 +102,7 @@ export const MemberDetailHeader = ({
|
|||
>
|
||||
{presenceLabel}
|
||||
</Badge>
|
||||
{leadContext && leadContext.percent > 0 && (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">
|
||||
{(leadContext.currentTokens / 1000).toFixed(1)}k /{' '}
|
||||
{(leadContext.contextWindow / 1000).toFixed(0)}k
|
||||
</span>
|
||||
)}
|
||||
{/* TODO: lead context token display disabled — usage formula is inaccurate */}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -148,10 +148,11 @@ export const MessageComposer = ({
|
|||
const selectedMember = members.find((m) => m.name === recipient);
|
||||
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
|
||||
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
|
||||
const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead';
|
||||
const leadContext = useStore((s) =>
|
||||
isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
|
||||
);
|
||||
// TODO: lead context ring disabled — usage formula is inaccurate
|
||||
// const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead';
|
||||
// const leadContext = useStore((s) =>
|
||||
// isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
|
||||
// );
|
||||
const supportsAttachments = isLeadRecipient && !!isTeamAlive;
|
||||
const canAttach = supportsAttachments && canAddMore;
|
||||
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
|
||||
|
|
@ -420,7 +421,7 @@ export const MessageComposer = ({
|
|||
disabled={sending}
|
||||
cornerAction={
|
||||
<div className="flex items-center gap-2">
|
||||
{leadContext && leadContext.percent > 0 && <ContextRing ctx={leadContext} />}
|
||||
{/* TODO: ContextRing disabled — usage formula is inaccurate */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -549,7 +549,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
const state = get();
|
||||
// Use display name from teams list or selected team data if available
|
||||
const teamSummary = state.teamByName[teamName];
|
||||
const displayName = teamSummary?.displayName || state.selectedTeamData?.config.name || teamName;
|
||||
const selectedTeamDisplayName =
|
||||
state.selectedTeamName === teamName ? state.selectedTeamData?.config.name : undefined;
|
||||
const displayName = teamSummary?.displayName || selectedTeamDisplayName || teamName;
|
||||
|
||||
const allTabs = state.getAllPaneTabs();
|
||||
const existing = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName);
|
||||
|
|
|
|||
|
|
@ -398,6 +398,10 @@ export interface HttpServerAPI {
|
|||
export interface TeamsAPI {
|
||||
list: () => Promise<TeamSummary[]>;
|
||||
getData: (teamName: string) => Promise<TeamData>;
|
||||
getClaudeLogs: (
|
||||
teamName: string,
|
||||
query?: import('./team').TeamClaudeLogsQuery
|
||||
) => Promise<import('./team').TeamClaudeLogsResponse>;
|
||||
deleteTeam: (teamName: string) => Promise<void>;
|
||||
restoreTeam: (teamName: string) => Promise<void>;
|
||||
permanentlyDeleteTeam: (teamName: string) => Promise<void>;
|
||||
|
|
|
|||
|
|
@ -311,6 +311,24 @@ export interface TeamChangeEvent {
|
|||
detail?: string;
|
||||
}
|
||||
|
||||
export interface TeamClaudeLogsQuery {
|
||||
/** Offset in lines from the newest log line (0 = newest). */
|
||||
offset?: number;
|
||||
/** Max number of lines to return. */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface TeamClaudeLogsResponse {
|
||||
/** Log lines ordered newest-first. */
|
||||
lines: string[];
|
||||
/** Total number of buffered lines available in memory. */
|
||||
total: number;
|
||||
/** True when there are older lines beyond the current window. */
|
||||
hasMore: boolean;
|
||||
/** ISO timestamp of the last observed CLI output for this team. */
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export type TeamProvisioningState =
|
||||
| 'idle'
|
||||
| 'validating'
|
||||
|
|
|
|||
Loading…
Reference in a new issue