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:
iliya 2026-03-05 15:32:34 +02:00
parent 82bea01e0f
commit fdb52922fe
17 changed files with 553 additions and 87 deletions

View file

@ -14,6 +14,7 @@ import {
TEAM_DELETE_TEAM, TEAM_DELETE_TEAM,
TEAM_GET_ALL_TASKS, TEAM_GET_ALL_TASKS,
TEAM_GET_ATTACHMENTS, TEAM_GET_ATTACHMENTS,
TEAM_GET_CLAUDE_LOGS,
TEAM_GET_DATA, TEAM_GET_DATA,
TEAM_GET_DELETED_TASKS, TEAM_GET_DELETED_TASKS,
TEAM_GET_LOGS_FOR_TASK, TEAM_GET_LOGS_FOR_TASK,
@ -108,6 +109,8 @@ import type {
TeamCreateRequest, TeamCreateRequest,
TeamCreateResponse, TeamCreateResponse,
TeamData, TeamData,
TeamClaudeLogsQuery,
TeamClaudeLogsResponse,
TeamLaunchRequest, TeamLaunchRequest,
TeamLaunchResponse, TeamLaunchResponse,
TeamMessageNotificationData, TeamMessageNotificationData,
@ -195,6 +198,7 @@ export function initializeTeamHandlers(
export function registerTeamHandlers(ipcMain: IpcMain): void { export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_LIST, handleListTeams); ipcMain.handle(TEAM_LIST, handleListTeams);
ipcMain.handle(TEAM_GET_DATA, handleGetData); ipcMain.handle(TEAM_GET_DATA, handleGetData);
ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs);
ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning); ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning);
ipcMain.handle(TEAM_CREATE, handleCreateTeam); ipcMain.handle(TEAM_CREATE, handleCreateTeam);
ipcMain.handle(TEAM_LAUNCH, handleLaunchTeam); ipcMain.handle(TEAM_LAUNCH, handleLaunchTeam);
@ -248,6 +252,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
export function removeTeamHandlers(ipcMain: IpcMain): void { export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_LIST); ipcMain.removeHandler(TEAM_LIST);
ipcMain.removeHandler(TEAM_GET_DATA); ipcMain.removeHandler(TEAM_GET_DATA);
ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS);
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING); ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
ipcMain.removeHandler(TEAM_CREATE); ipcMain.removeHandler(TEAM_CREATE);
ipcMain.removeHandler(TEAM_LAUNCH); 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( async function handleCreateTeam(
event: IpcMainInvokeEvent, event: IpcMainInvokeEvent,
request: unknown request: unknown

View file

@ -114,6 +114,14 @@ interface ProvisioningRun {
progress: TeamProvisioningProgress; progress: TeamProvisioningProgress;
stdoutBuffer: string; stdoutBuffer: string;
stderrBuffer: 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; processKilled: boolean;
finalizingByTimeout: boolean; finalizingByTimeout: boolean;
cancelRequested: boolean; cancelRequested: boolean;
@ -719,7 +727,8 @@ ${membersFooter}
function buildLaunchPrompt( function buildLaunchPrompt(
request: TeamLaunchRequest, request: TeamLaunchRequest,
members: TeamCreateRequest['members'], members: TeamCreateRequest['members'],
tasks: TeamTask[] tasks: TeamTask[],
isResume: boolean
): string { ): string {
const membersBlock = buildMembersPrompt(members); const membersBlock = buildMembersPrompt(members);
const userPromptBlock = request.prompt?.trim() const userPromptBlock = request.prompt?.trim()
@ -828,7 +837,9 @@ ${memberSpawnInstructions}
? `Members:\n${membersBlock}` ? `Members:\n${membersBlock}`
: 'Members: (none — solo team lead)'; : '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 running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
You are "${leadName}", the team lead. You are "${leadName}", the team lead.
@ -967,6 +978,8 @@ interface CachedProbeResult {
let cachedProbeResult: CachedProbeResult | null = null; let cachedProbeResult: CachedProbeResult | null = null;
export class TeamProvisioningService { export class TeamProvisioningService {
private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000;
private readonly runs = new Map<string, ProvisioningRun>(); private readonly runs = new Map<string, ProvisioningRun>();
private readonly activeByTeam = new Map<string, string>(); private readonly activeByTeam = new Map<string, string>();
private readonly teamOpLocks = new Map<string, Promise<void>>(); private readonly teamOpLocks = new Map<string, Promise<void>>();
@ -983,6 +996,71 @@ export class TeamProvisioningService {
private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore() 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. * Serializes operations per team name using promise-chaining.
* Same pattern as withInboxLock / withTaskLock. * 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. * Detects auth failure keywords in stderr/stdout during provisioning.
* On first detection: kills process, waits, and respawns automatically. * On first detection: kills process, waits, and respawns automatically.
@ -1250,6 +1389,10 @@ export class TeamProvisioningService {
// Reset buffers for fresh attempt // Reset buffers for fresh attempt
run.stdoutBuffer = ''; run.stdoutBuffer = '';
run.stderrBuffer = ''; run.stderrBuffer = '';
run.claudeLogLines = [];
run.stdoutLogLineBuf = '';
run.stderrLogLineBuf = '';
run.claudeLogsUpdatedAt = undefined;
run.authFailureRetried = true; run.authFailureRetried = true;
updateProgress(run, 'spawning', 'Auth failed — retrying after short delay'); updateProgress(run, 'spawning', 'Auth failed — retrying after short delay');
@ -1362,6 +1505,7 @@ export class TeamProvisioningService {
let stdoutLineBuf = ''; let stdoutLineBuf = '';
child.stdout.on('data', (chunk: Buffer) => { child.stdout.on('data', (chunk: Buffer) => {
const text = chunk.toString('utf8'); const text = chunk.toString('utf8');
this.appendCliLogs(run, 'stdout', text);
run.stdoutBuffer += text; run.stdoutBuffer += text;
if (run.stdoutBuffer.length > STDOUT_RING_LIMIT) { if (run.stdoutBuffer.length > STDOUT_RING_LIMIT) {
run.stdoutBuffer = run.stdoutBuffer.slice(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 { } catch {
// Not valid JSON — check for auth failure in raw text output // Not valid JSON — check for auth failure in raw text output
this.handleAuthFailureInOutput(run, trimmed, 'stdout'); 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) => { child.stderr.on('data', (chunk: Buffer) => {
const text = chunk.toString('utf8'); const text = chunk.toString('utf8');
this.appendCliLogs(run, 'stderr', text);
run.stderrBuffer += text; run.stderrBuffer += text;
if (run.stderrBuffer.length > STDERR_RING_LIMIT) { if (run.stderrBuffer.length > STDERR_RING_LIMIT) {
run.stderrBuffer = run.stderrBuffer.slice(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 // Detect auth failure early instead of waiting for 5-minute timeout
this.handleAuthFailureInOutput(run, text, 'stderr'); this.handleAuthFailureInOutput(run, text, 'stderr');
if (this.hasApiError(text) && !this.isAuthFailureWarning(text)) {
this.failProvisioningWithApiError(run, text);
}
const currentTs = Date.now(); const currentTs = Date.now();
if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) { if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) {
@ -1460,6 +1611,10 @@ export class TeamProvisioningService {
startedAt, startedAt,
stdoutBuffer: '', stdoutBuffer: '',
stderrBuffer: '', stderrBuffer: '',
claudeLogLines: [],
stdoutLogLineBuf: '',
stderrLogLineBuf: '',
claudeLogsUpdatedAt: undefined,
processKilled: false, processKilled: false,
finalizingByTimeout: false, finalizingByTimeout: false,
cancelRequested: false, cancelRequested: false,
@ -1744,6 +1899,10 @@ export class TeamProvisioningService {
startedAt, startedAt,
stdoutBuffer: '', stdoutBuffer: '',
stderrBuffer: '', stderrBuffer: '',
claudeLogLines: [],
stdoutLogLineBuf: '',
stderrLogLineBuf: '',
claudeLogsUpdatedAt: undefined,
processKilled: false, processKilled: false,
finalizingByTimeout: false, finalizingByTimeout: false,
cancelRequested: 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>; let child: ReturnType<typeof spawn>;
const { env: shellEnv } = await this.buildProvisioningEnv(); const { env: shellEnv } = await this.buildProvisioningEnv();
const launchArgs = [ const launchArgs = [
@ -2480,6 +2644,10 @@ export class TeamProvisioningService {
// Auth failures sometimes show up as assistant text (e.g. "401", "Please run /login") // 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". // rather than stderr or a result.subtype=error. Detect early to avoid false "ready".
this.handleAuthFailureInOutput(run, text, 'assistant'); 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)}`); logger.debug(`[${run.teamName}] assistant: ${text.slice(0, 200)}`);
// During provisioning (before provisioningComplete), accumulate for live UI preview. // During provisioning (before provisioningComplete), accumulate for live UI preview.
// Emission is handled by the throttled emitLogsProgress() in the stdout data handler. // 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 // Handle compact_boundary — context was compacted, next assistant message will carry fresh usage
if (msg.type === 'system') { if (msg.type === 'system') {
const sub = typeof msg.subtype === 'string' ? msg.subtype : undefined; const sub = typeof msg.subtype === 'string' ? msg.subtype : undefined;
if (sub === 'compact_boundary' && run.leadContextUsage) { if (sub === 'compact_boundary') {
run.leadContextUsage.lastUsageMessageId = null; if (run.leadContextUsage) {
logger.info(`[${run.teamName}] compact_boundary — context will refresh on next turn`); 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> { 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) 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 // Prevent false "ready" when auth failure was printed as assistant text or logs
// but the filesystem monitor observed files on disk. // but the filesystem monitor observed files on disk.
const authFailureText = [ const preCompleteText = [
buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer), buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer),
run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('\n') : '', run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('\n') : '',
] ]
.filter(Boolean) .filter(Boolean)
.join('\n') .join('\n')
.trim(); .trim();
if (authFailureText && this.isAuthFailureWarning(authFailureText)) { if (preCompleteText && this.hasApiError(preCompleteText) && !this.isAuthFailureWarning(preCompleteText)) {
this.handleAuthFailureInOutput(run, authFailureText, 'pre-complete'); this.failProvisioningWithApiError(run, preCompleteText);
return;
}
if (preCompleteText && this.isAuthFailureWarning(preCompleteText)) {
this.handleAuthFailureInOutput(run, preCompleteText, 'pre-complete');
return; return;
} }

View file

@ -210,6 +210,9 @@ export const TEAM_LIST = 'team:list';
/** Get detailed team data */ /** Get detailed team data */
export const TEAM_GET_DATA = 'team:getData'; 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 */ /** Update team kanban state */
export const TEAM_UPDATE_KANBAN = 'team:updateKanban'; export const TEAM_UPDATE_KANBAN = 'team:updateKanban';

View file

@ -70,6 +70,7 @@ import {
TEAM_DELETE_TEAM, TEAM_DELETE_TEAM,
TEAM_GET_ALL_TASKS, TEAM_GET_ALL_TASKS,
TEAM_GET_ATTACHMENTS, TEAM_GET_ATTACHMENTS,
TEAM_GET_CLAUDE_LOGS,
TEAM_GET_DATA, TEAM_GET_DATA,
TEAM_GET_DELETED_TASKS, TEAM_GET_DELETED_TASKS,
TEAM_GET_LOGS_FOR_TASK, TEAM_GET_LOGS_FOR_TASK,
@ -197,6 +198,8 @@ import type {
TaskChangeSetV2, TaskChangeSetV2,
TaskComment, TaskComment,
TeamChangeEvent, TeamChangeEvent,
TeamClaudeLogsQuery,
TeamClaudeLogsResponse,
TeamConfig, TeamConfig,
TeamCreateConfigRequest, TeamCreateConfigRequest,
TeamCreateRequest, TeamCreateRequest,
@ -696,6 +699,9 @@ const electronAPI: ElectronAPI = {
getData: async (teamName: string) => { getData: async (teamName: string) => {
return invokeIpcWithResult<TeamData>(TEAM_GET_DATA, teamName); 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) => { deleteTeam: async (teamName: string) => {
return invokeIpcWithResult<void>(TEAM_DELETE_TEAM, teamName); return invokeIpcWithResult<void>(TEAM_DELETE_TEAM, teamName);
}, },

View file

@ -45,6 +45,8 @@ import type {
SshLastConnection, SshLastConnection,
SubagentDetail, SubagentDetail,
TeamChangeEvent, TeamChangeEvent,
TeamClaudeLogsQuery,
TeamClaudeLogsResponse,
TeamCreateRequest, TeamCreateRequest,
TeamCreateResponse, TeamCreateResponse,
TeamData, TeamData,
@ -644,6 +646,13 @@ export class HttpAPIClient implements ElectronAPI {
getData: async (_teamName: string): Promise<TeamData> => { getData: async (_teamName: string): Promise<TeamData> => {
throw new Error('Teams detail is not available in browser mode'); 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> => { deleteTeam: async (_teamName: string): Promise<void> => {
throw new Error('Team deletion is not available in browser mode'); throw new Error('Team deletion is not available in browser mode');
}, },

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

View file

@ -44,27 +44,66 @@ function formatElapsed(seconds: number): string {
return `${m}:${String(s).padStart(2, '0')}`; return `${m}:${String(s).padStart(2, '0')}`;
} }
function useElapsedTimer(startedAt?: string): string | null { function useElapsedTimer(startedAt?: string, isRunning = true): string | null {
const [elapsed, setElapsed] = useState<string | null>(null); const [elapsedSeconds, setElapsedSeconds] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
if (!startedAt) return () => setElapsed(null); if (!startedAt) {
setElapsedSeconds(null);
return;
}
const startMs = Date.parse(startedAt); 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 tick = (): void => {
const seconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000)); setElapsedSeconds(computeElapsedSeconds());
setElapsed(formatElapsed(seconds));
}; };
tick(); tick();
const id = window.setInterval(tick, 1000); const id = window.setInterval(tick, 1000);
return () => { return () => {
window.clearInterval(id); window.clearInterval(id);
}; };
}, [startedAt]); }, [startedAt, isRunning]);
if (!startedAt) return null; 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 = ({ export const ProvisioningProgressBlock = ({
@ -81,11 +120,12 @@ export const ProvisioningProgressBlock = ({
assistantOutput, assistantOutput,
className, className,
}: ProvisioningProgressBlockProps): React.JSX.Element => { }: ProvisioningProgressBlockProps): React.JSX.Element => {
const elapsed = useElapsedTimer(startedAt); const elapsed = useElapsedTimer(startedAt, loading);
const [logsOpen, setLogsOpen] = useState(false); const [logsOpen, setLogsOpen] = useState(() => tone === 'error' && Boolean(cliLogsTail));
const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen); const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen);
const outputScrollRef = useRef<HTMLDivElement>(null); const outputScrollRef = useRef<HTMLDivElement>(null);
const isError = tone === 'error'; const isError = tone === 'error';
const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError);
// Auto-scroll assistant output // Auto-scroll assistant output
useEffect(() => { useEffect(() => {
@ -99,6 +139,14 @@ export const ProvisioningProgressBlock = ({
setLiveOutputOpen(defaultLiveOutputOpen); setLiveOutputOpen(defaultLiveOutputOpen);
}, [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 ( return (
<div <div
className={cn( className={cn(
@ -190,8 +238,8 @@ export const ProvisioningProgressBlock = ({
isError && 'border-red-500/40' isError && 'border-red-500/40'
)} )}
> >
{assistantOutput ? ( {displayAssistantOutput ? (
<MarkdownViewer content={assistantOutput} bare maxHeight="max-h-none" /> <MarkdownViewer content={displayAssistantOutput} bare maxHeight="max-h-none" />
) : ( ) : (
<p <p
className={cn( className={cn(

View file

@ -70,6 +70,7 @@ import { MessageComposer } from './messages/MessageComposer';
import { MessagesFilterPopover } from './messages/MessagesFilterPopover'; import { MessagesFilterPopover } from './messages/MessagesFilterPopover';
import { ChangeReviewDialog } from './review/ChangeReviewDialog'; import { ChangeReviewDialog } from './review/ChangeReviewDialog';
import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { CollapsibleTeamSection } from './CollapsibleTeamSection';
import { ClaudeLogsSection } from './ClaudeLogsSection';
import { ProcessesSection } from './ProcessesSection'; import { ProcessesSection } from './ProcessesSection';
import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { TeamProvisioningBanner } from './TeamProvisioningBanner';
import { TeamSessionsSection } from './TeamSessionsSection'; import { TeamSessionsSection } from './TeamSessionsSection';
@ -1171,6 +1172,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
</CollapsibleTeamSection> </CollapsibleTeamSection>
)} )}
<ClaudeLogsSection teamName={teamName} />
<CollapsibleTeamSection <CollapsibleTeamSection
sectionId="messages" sectionId="messages"
title="Messages" title="Messages"

View file

@ -255,6 +255,21 @@ export const TeamListView = (): React.JSX.Element => {
}; };
}, [electronMode, teams]); }, [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(() => { const currentProjectPath = useMemo(() => {
if (viewMode === 'grouped') { if (viewMode === 'grouped') {
const repo = repositoryGroups.find((r) => r.id === selectedRepositoryId); const repo = repositoryGroups.find((r) => r.id === selectedRepositoryId);

View file

@ -491,10 +491,11 @@ export const CreateTeamDialog = ({
activeError?.includes('Team already exists') === true && request.teamName.length > 0; activeError?.includes('Team already exists') === true && request.teamName.length > 0;
const conflictingTeam = useMemo(() => { const conflictingTeam = useMemo(() => {
if (!launchTeam) return null;
if (!activeTeams?.length || !effectiveCwd) return null; if (!activeTeams?.length || !effectiveCwd) return null;
const norm = normalizePath(effectiveCwd); const norm = normalizePath(effectiveCwd);
return activeTeams.find((t) => normalizePath(t.projectPath) === norm) ?? null; return activeTeams.find((t) => normalizePath(t.projectPath) === norm) ?? null;
}, [activeTeams, effectiveCwd]); }, [activeTeams, effectiveCwd, launchTeam]);
// Reset dismiss when conflict target changes // Reset dismiss when conflict target changes
useEffect(() => { 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 ( return (
<Dialog <Dialog
open={open} open={open}
@ -580,13 +593,16 @@ export const CreateTeamDialog = ({
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" /> <AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
<div className="min-w-0 flex-1 space-y-1"> <div className="min-w-0 flex-1 space-y-1">
<p className="font-medium text-amber-300"> <p className="font-medium text-amber-300">
Team &ldquo;{conflictingTeam.displayName}&rdquo; is already running in this Another team &ldquo;{conflictingTeam.displayName}&rdquo; is already running for
project this working directory
</p> </p>
<p className="text-amber-300/80"> <p className="text-amber-300/80">
Running two teams in the same directory is risky they may conflict editing the 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. same files. Consider using a different directory or a git worktree for isolation.
</p> </p>
<p className="text-[11px] text-amber-300/70">
Working directory: <span className="font-mono">{effectiveCwd}</span>
</p>
</div> </div>
<button <button
type="button" type="button"
@ -641,7 +657,7 @@ export const CreateTeamDialog = ({
id="team-name" id="team-name"
className="h-8 text-xs" className="h-8 text-xs"
value={teamName} value={teamName}
onChange={(event) => setTeamName(event.target.value)} onChange={(event) => handleTeamNameChange(event.target.value)}
placeholder="team-alpha" placeholder="team-alpha"
/> />
{existingTeamNames.includes(sanitizedTeamName) ? ( {existingTeamNames.includes(sanitizedTeamName) ? (

View file

@ -319,13 +319,16 @@ export const LaunchTeamDialog = ({
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" /> <AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
<div className="min-w-0 flex-1 space-y-1"> <div className="min-w-0 flex-1 space-y-1">
<p className="font-medium text-amber-300"> <p className="font-medium text-amber-300">
Team &ldquo;{conflictingTeam.displayName}&rdquo; is already running in this Another team &ldquo;{conflictingTeam.displayName}&rdquo; is already running for
project this working directory
</p> </p>
<p className="text-amber-300/80"> <p className="text-amber-300/80">
Running two teams in the same directory is risky they may conflict editing the 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. same files. Consider using a different directory or a git worktree for isolation.
</p> </p>
<p className="text-[11px] text-amber-300/70">
Working directory: <span className="font-mono">{effectiveCwd}</span>
</p>
</div> </div>
<button <button
type="button" type="button"

View file

@ -1,7 +1,7 @@
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors'; 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 { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers'; import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react'; import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
@ -40,18 +40,13 @@ export const MemberCard = ({
onSendMessage, onSendMessage,
onAssignTask, onAssignTask,
}: MemberCardProps): React.JSX.Element => { }: MemberCardProps): React.JSX.Element => {
const teamName = useStore((s) => s.selectedTeamName); // TODO: lead context display disabled — usage formula is inaccurate
const leadContext = useStore((s) => // const teamName = useStore((s) => s.selectedTeamName);
member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined // const leadContext = useStore((s) =>
); // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
// );
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity); const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
const presenceLabel = getPresenceLabel( const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
member,
isTeamAlive,
isTeamProvisioning,
leadActivity,
leadContext?.percent
);
const colors = getTeamColorSet(memberColor); const colors = getTeamColorSet(memberColor);
const pending = taskCounts?.pending ?? 0; const pending = taskCounts?.pending ?? 0;
const inProgress = taskCounts?.inProgress ?? 0; const inProgress = taskCounts?.inProgress ?? 0;
@ -182,29 +177,7 @@ export const MemberCard = ({
/> />
</div> </div>
)} )}
{leadContext && leadContext.percent > 0 && ( {/* TODO: lead context bar disabled — usage formula is inaccurate */}
<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>
)}
</div> </div>
{!isRemoved && ( {!isRemoved && (
<div className="flex shrink-0 items-center gap-0.5"> <div className="flex shrink-0 items-center gap-0.5">

View file

@ -3,7 +3,7 @@ import { useState } from 'react';
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog'; import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog';
import { getTeamColorSet } from '@renderer/constants/teamColors'; 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 { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers'; import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
import { Pencil } from 'lucide-react'; import { Pencil } from 'lucide-react';
@ -31,20 +31,15 @@ export const MemberDetailHeader = ({
}: MemberDetailHeaderProps): React.JSX.Element => { }: MemberDetailHeaderProps): React.JSX.Element => {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const teamName = useStore((s) => s.selectedTeamName); // TODO: lead context display disabled — usage formula is inaccurate
const leadContext = useStore((s) => // const teamName = useStore((s) => s.selectedTeamName);
member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined // const leadContext = useStore((s) =>
); // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
// );
const colors = getTeamColorSet(member.color ?? ''); const colors = getTeamColorSet(member.color ?? '');
const role = member.role || formatAgentRole(member.agentType); const role = member.role || formatAgentRole(member.agentType);
const presenceLabel = getPresenceLabel( const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
member,
isTeamAlive,
isTeamProvisioning,
leadActivity,
leadContext?.percent
);
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity); const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
const canEditRole = const canEditRole =
@ -107,12 +102,7 @@ export const MemberDetailHeader = ({
> >
{presenceLabel} {presenceLabel}
</Badge> </Badge>
{leadContext && leadContext.percent > 0 && ( {/* TODO: lead context token display disabled — usage formula is inaccurate */}
<span className="text-[10px] text-[var(--color-text-muted)]">
{(leadContext.currentTokens / 1000).toFixed(1)}k /{' '}
{(leadContext.contextWindow / 1000).toFixed(0)}k
</span>
)}
</> </>
)} )}
</div> </div>

View file

@ -148,10 +148,11 @@ export const MessageComposer = ({
const selectedMember = members.find((m) => m.name === recipient); const selectedMember = members.find((m) => m.name === recipient);
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined; const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead'; const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead'; // TODO: lead context ring disabled — usage formula is inaccurate
const leadContext = useStore((s) => // const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead';
isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined // const leadContext = useStore((s) =>
); // isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
// );
const supportsAttachments = isLeadRecipient && !!isTeamAlive; const supportsAttachments = isLeadRecipient && !!isTeamAlive;
const canAttach = supportsAttachments && canAddMore; const canAttach = supportsAttachments && canAddMore;
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments; const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
@ -420,7 +421,7 @@ export const MessageComposer = ({
disabled={sending} disabled={sending}
cornerAction={ cornerAction={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{leadContext && leadContext.percent > 0 && <ContextRing ctx={leadContext} />} {/* TODO: ContextRing disabled — usage formula is inaccurate */}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button

View file

@ -549,7 +549,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
const state = get(); const state = get();
// Use display name from teams list or selected team data if available // Use display name from teams list or selected team data if available
const teamSummary = state.teamByName[teamName]; 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 allTabs = state.getAllPaneTabs();
const existing = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName); const existing = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName);

View file

@ -398,6 +398,10 @@ export interface HttpServerAPI {
export interface TeamsAPI { export interface TeamsAPI {
list: () => Promise<TeamSummary[]>; list: () => Promise<TeamSummary[]>;
getData: (teamName: string) => Promise<TeamData>; getData: (teamName: string) => Promise<TeamData>;
getClaudeLogs: (
teamName: string,
query?: import('./team').TeamClaudeLogsQuery
) => Promise<import('./team').TeamClaudeLogsResponse>;
deleteTeam: (teamName: string) => Promise<void>; deleteTeam: (teamName: string) => Promise<void>;
restoreTeam: (teamName: string) => Promise<void>; restoreTeam: (teamName: string) => Promise<void>;
permanentlyDeleteTeam: (teamName: string) => Promise<void>; permanentlyDeleteTeam: (teamName: string) => Promise<void>;

View file

@ -311,6 +311,24 @@ export interface TeamChangeEvent {
detail?: string; 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 = export type TeamProvisioningState =
| 'idle' | 'idle'
| 'validating' | 'validating'