feat: enhance session and subagent routes with cache bypass functionality
- Updated session and subagent route handlers to support an optional `bypassCache` query parameter, allowing clients to bypass cached responses. - Enhanced README to include a Discord link for community engagement. - Improved CSS for lightbox toolbar buttons to address macOS hit-testing issues. - Refactored task ID linkification in markdown to ensure accurate matching and improved functionality in various components.
This commit is contained in:
parent
9bfcbb182c
commit
dba2d98923
14 changed files with 548 additions and 309 deletions
|
|
@ -10,7 +10,8 @@
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest"><img src="https://img.shields.io/github/v/release/777genius/claude_agent_teams_ui?style=flat-square&label=version&color=blue" alt="Latest Release" /></a>
|
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest"><img src="https://img.shields.io/github/v/release/777genius/claude_agent_teams_ui?style=flat-square&label=version&color=blue" alt="Latest Release" /></a>
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml"><img src="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>
|
<a href="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml"><img src="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>
|
||||||
|
<a href="https://discord.gg/m5gszZKG"><img src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|
|
||||||
|
|
@ -137,78 +137,76 @@ export function registerSessionRoutes(app: FastifyInstance, services: HttpServic
|
||||||
);
|
);
|
||||||
|
|
||||||
// Session detail
|
// Session detail
|
||||||
app.get<{ Params: { projectId: string; sessionId: string } }>(
|
app.get<{
|
||||||
'/api/projects/:projectId/sessions/:sessionId',
|
Params: { projectId: string; sessionId: string };
|
||||||
async (request) => {
|
Querystring: { bypassCache?: string };
|
||||||
try {
|
}>('/api/projects/:projectId/sessions/:sessionId', async (request) => {
|
||||||
const validatedProject = validateProjectId(request.params.projectId);
|
try {
|
||||||
const validatedSession = validateSessionId(request.params.sessionId);
|
const validatedProject = validateProjectId(request.params.projectId);
|
||||||
if (!validatedProject.valid || !validatedSession.valid) {
|
const validatedSession = validateSessionId(request.params.sessionId);
|
||||||
logger.error(
|
if (!validatedProject.valid || !validatedSession.valid) {
|
||||||
`GET session-detail rejected: ${validatedProject.error ?? validatedSession.error ?? 'unknown'}`
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const safeProjectId = validatedProject.value!;
|
|
||||||
const safeSessionId = validatedSession.value!;
|
|
||||||
const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId);
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
let sessionDetail = services.dataCache.get(cacheKey);
|
|
||||||
if (sessionDetail) {
|
|
||||||
return sessionDetail;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fsType = services.projectScanner.getFileSystemProvider().type;
|
|
||||||
// In SSH mode, avoid an extra deep metadata scan before full parse.
|
|
||||||
const session = await services.projectScanner.getSessionWithOptions(
|
|
||||||
safeProjectId,
|
|
||||||
safeSessionId,
|
|
||||||
{
|
|
||||||
metadataLevel: fsType === 'ssh' ? 'light' : 'deep',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (!session) {
|
|
||||||
logger.error(`Session not found: ${safeSessionId}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse session messages
|
|
||||||
const parsedSession = await services.sessionParser.parseSession(
|
|
||||||
safeProjectId,
|
|
||||||
safeSessionId
|
|
||||||
);
|
|
||||||
|
|
||||||
// Resolve subagents
|
|
||||||
const subagents = await services.subagentResolver.resolveSubagents(
|
|
||||||
safeProjectId,
|
|
||||||
safeSessionId,
|
|
||||||
parsedSession.taskCalls,
|
|
||||||
parsedSession.messages
|
|
||||||
);
|
|
||||||
session.hasSubagents = subagents.length > 0;
|
|
||||||
|
|
||||||
// Build session detail with chunks
|
|
||||||
sessionDetail = services.chunkBuilder.buildSessionDetail(
|
|
||||||
session,
|
|
||||||
parsedSession.messages,
|
|
||||||
subagents
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
services.dataCache.set(cacheKey, sessionDetail);
|
|
||||||
|
|
||||||
return sessionDetail;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
logger.error(
|
||||||
`Error in GET session-detail for ${request.params.projectId}/${request.params.sessionId}:`,
|
`GET session-detail rejected: ${validatedProject.error ?? validatedSession.error ?? 'unknown'}`
|
||||||
error
|
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const safeProjectId = validatedProject.value!;
|
||||||
|
const safeSessionId = validatedSession.value!;
|
||||||
|
const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId);
|
||||||
|
const bypassCache = request.query?.bypassCache === 'true';
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
let sessionDetail = services.dataCache.get(cacheKey);
|
||||||
|
if (sessionDetail && !bypassCache) {
|
||||||
|
return sessionDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fsType = services.projectScanner.getFileSystemProvider().type;
|
||||||
|
// In SSH mode, avoid an extra deep metadata scan before full parse.
|
||||||
|
const session = await services.projectScanner.getSessionWithOptions(
|
||||||
|
safeProjectId,
|
||||||
|
safeSessionId,
|
||||||
|
{
|
||||||
|
metadataLevel: fsType === 'ssh' ? 'light' : 'deep',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!session) {
|
||||||
|
logger.error(`Session not found: ${safeSessionId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse session messages
|
||||||
|
const parsedSession = await services.sessionParser.parseSession(safeProjectId, safeSessionId);
|
||||||
|
|
||||||
|
// Resolve subagents
|
||||||
|
const subagents = await services.subagentResolver.resolveSubagents(
|
||||||
|
safeProjectId,
|
||||||
|
safeSessionId,
|
||||||
|
parsedSession.taskCalls,
|
||||||
|
parsedSession.messages
|
||||||
|
);
|
||||||
|
session.hasSubagents = subagents.length > 0;
|
||||||
|
|
||||||
|
// Build session detail with chunks
|
||||||
|
sessionDetail = services.chunkBuilder.buildSessionDetail(
|
||||||
|
session,
|
||||||
|
parsedSession.messages,
|
||||||
|
subagents
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
services.dataCache.set(cacheKey, sessionDetail);
|
||||||
|
|
||||||
|
return sessionDetail;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Error in GET session-detail for ${request.params.projectId}/${request.params.sessionId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
|
|
||||||
// Conversation groups
|
// Conversation groups
|
||||||
app.get<{ Params: { projectId: string; sessionId: string } }>(
|
app.get<{ Params: { projectId: string; sessionId: string } }>(
|
||||||
|
|
|
||||||
|
|
@ -15,63 +15,64 @@ import type { FastifyInstance } from 'fastify';
|
||||||
const logger = createLogger('HTTP:subagents');
|
const logger = createLogger('HTTP:subagents');
|
||||||
|
|
||||||
export function registerSubagentRoutes(app: FastifyInstance, services: HttpServices): void {
|
export function registerSubagentRoutes(app: FastifyInstance, services: HttpServices): void {
|
||||||
app.get<{ Params: { projectId: string; sessionId: string; subagentId: string } }>(
|
app.get<{
|
||||||
'/api/projects/:projectId/sessions/:sessionId/subagents/:subagentId',
|
Params: { projectId: string; sessionId: string; subagentId: string };
|
||||||
async (request) => {
|
Querystring: { bypassCache?: string };
|
||||||
try {
|
}>('/api/projects/:projectId/sessions/:sessionId/subagents/:subagentId', async (request) => {
|
||||||
const validatedProject = validateProjectId(request.params.projectId);
|
try {
|
||||||
const validatedSession = validateSessionId(request.params.sessionId);
|
const validatedProject = validateProjectId(request.params.projectId);
|
||||||
const validatedSubagent = validateSubagentId(request.params.subagentId);
|
const validatedSession = validateSessionId(request.params.sessionId);
|
||||||
if (!validatedProject.valid || !validatedSession.valid || !validatedSubagent.valid) {
|
const validatedSubagent = validateSubagentId(request.params.subagentId);
|
||||||
logger.error(
|
if (!validatedProject.valid || !validatedSession.valid || !validatedSubagent.valid) {
|
||||||
`GET subagent-detail rejected: ${
|
logger.error(
|
||||||
validatedProject.error ??
|
`GET subagent-detail rejected: ${
|
||||||
validatedSession.error ??
|
validatedProject.error ??
|
||||||
validatedSubagent.error ??
|
validatedSession.error ??
|
||||||
'Invalid parameters'
|
validatedSubagent.error ??
|
||||||
}`
|
'Invalid parameters'
|
||||||
);
|
}`
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const safeProjectId = validatedProject.value!;
|
|
||||||
const safeSessionId = validatedSession.value!;
|
|
||||||
const safeSubagentId = validatedSubagent.value!;
|
|
||||||
|
|
||||||
const cacheKey = `subagent-${safeProjectId}-${safeSessionId}-${safeSubagentId}`;
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
let subagentDetail = services.dataCache.getSubagent(cacheKey);
|
|
||||||
if (subagentDetail) {
|
|
||||||
return subagentDetail;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fsProvider = services.projectScanner.getFileSystemProvider();
|
|
||||||
const projectsDir = services.projectScanner.getProjectsDir();
|
|
||||||
|
|
||||||
const builtDetail = await services.chunkBuilder.buildSubagentDetail(
|
|
||||||
safeProjectId,
|
|
||||||
safeSessionId,
|
|
||||||
safeSubagentId,
|
|
||||||
services.sessionParser,
|
|
||||||
services.subagentResolver,
|
|
||||||
fsProvider,
|
|
||||||
projectsDir
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!builtDetail) {
|
|
||||||
logger.error(`Subagent not found: ${safeSubagentId}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
subagentDetail = builtDetail;
|
|
||||||
services.dataCache.setSubagent(cacheKey, subagentDetail);
|
|
||||||
|
|
||||||
return subagentDetail;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error in GET subagent-detail for ${request.params.subagentId}:`, error);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const safeProjectId = validatedProject.value!;
|
||||||
|
const safeSessionId = validatedSession.value!;
|
||||||
|
const safeSubagentId = validatedSubagent.value!;
|
||||||
|
const bypassCache = request.query?.bypassCache === 'true';
|
||||||
|
|
||||||
|
const cacheKey = `subagent-${safeProjectId}-${safeSessionId}-${safeSubagentId}`;
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
let subagentDetail = services.dataCache.getSubagent(cacheKey);
|
||||||
|
if (subagentDetail && !bypassCache) {
|
||||||
|
return subagentDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fsProvider = services.projectScanner.getFileSystemProvider();
|
||||||
|
const projectsDir = services.projectScanner.getProjectsDir();
|
||||||
|
|
||||||
|
const builtDetail = await services.chunkBuilder.buildSubagentDetail(
|
||||||
|
safeProjectId,
|
||||||
|
safeSessionId,
|
||||||
|
safeSubagentId,
|
||||||
|
services.sessionParser,
|
||||||
|
services.subagentResolver,
|
||||||
|
fsProvider,
|
||||||
|
projectsDir
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!builtDetail) {
|
||||||
|
logger.error(`Subagent not found: ${safeSubagentId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
subagentDetail = builtDetail;
|
||||||
|
services.dataCache.setSubagent(cacheKey, subagentDetail);
|
||||||
|
|
||||||
|
return subagentDetail;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error in GET subagent-detail for ${request.params.subagentId}:`, error);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,16 @@ interface ProvisioningRun {
|
||||||
} | null;
|
} | null;
|
||||||
/** Pending tool approval requests awaiting user response (control_request protocol). */
|
/** Pending tool approval requests awaiting user response (control_request protocol). */
|
||||||
pendingApprovals: Map<string, ToolApprovalRequest>;
|
pendingApprovals: Map<string, ToolApprovalRequest>;
|
||||||
|
/**
|
||||||
|
* Post-compact context reinjection lifecycle.
|
||||||
|
* - pendingPostCompactReminder: compact_boundary was received; waiting for idle to inject.
|
||||||
|
* - postCompactReminderInFlight: the reminder turn has been injected via stdin, waiting for result.
|
||||||
|
* - suppressPostCompactReminderOutput: true while processing a reminder turn — suppress
|
||||||
|
* low-value acknowledgement text so the user doesn't see "OK, I'll remember that."
|
||||||
|
*/
|
||||||
|
pendingPostCompactReminder: boolean;
|
||||||
|
postCompactReminderInFlight: boolean;
|
||||||
|
suppressPostCompactReminderOutput: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type LeadActivityState = 'active' | 'idle' | 'offline';
|
type LeadActivityState = 'active' | 'idle' | 'offline';
|
||||||
|
|
@ -553,6 +563,76 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the durable lead context — constraints, communication protocol, teamctl ops,
|
||||||
|
* and agent block policy — that must survive context compaction.
|
||||||
|
*
|
||||||
|
* Used by: buildProvisioningPrompt, buildLaunchPrompt, and post-compact reinjection.
|
||||||
|
*/
|
||||||
|
function buildPersistentLeadContext(opts: {
|
||||||
|
teamName: string;
|
||||||
|
leadName: string;
|
||||||
|
isSolo: boolean;
|
||||||
|
members: TeamCreateRequest['members'];
|
||||||
|
}): string {
|
||||||
|
const { teamName, leadName, isSolo, members } = opts;
|
||||||
|
const languageInstruction = getAgentLanguageInstruction();
|
||||||
|
const agentBlockPolicy = buildAgentBlockUsagePolicy();
|
||||||
|
const teamCtlOps = buildTeamCtlOpsInstructions(teamName, leadName);
|
||||||
|
|
||||||
|
const soloConstraint = isSolo
|
||||||
|
? `\n- SOLO MODE: This team CURRENTLY has ZERO teammates.` +
|
||||||
|
`\n - FORBIDDEN (until teammates exist): Do NOT spawn teammates via the Task tool with a team_name parameter — there are no teammates to spawn yet.` +
|
||||||
|
`\n - FORBIDDEN (until teammates exist): Do NOT call SendMessage to any teammate name — no teammates exist yet.` +
|
||||||
|
`\n - ALLOWED: You may message "user" (the human operator) via SendMessage.` +
|
||||||
|
`\n - ALLOWED: You may use the Task tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` +
|
||||||
|
`\n - If teammates are added later (e.g. via UI), you may then spawn them using the Task tool with team_name + name.` +
|
||||||
|
`\n - Work on tasks directly yourself. Use subagents for research and parallel work as needed.` +
|
||||||
|
`\n - PROGRESS REPORTING (MANDATORY): Since you have no teammates, "user" is your only communication channel.` +
|
||||||
|
`\n - SendMessage "user" at minimum: when you start a task (after marking it in_progress), when you complete a task, and when you hit a meaningful milestone/blocker/decision.` +
|
||||||
|
`\n - Avoid long silent stretches. If something is taking longer than expected, send a brief update and the next step.` +
|
||||||
|
`\n - TASK STATUS DISCIPLINE (MANDATORY):` +
|
||||||
|
`\n - Only move a task to in_progress when you are actively starting work on it.` +
|
||||||
|
`\n - Only move a task to completed when it is truly finished.` +
|
||||||
|
`\n - Never bulk-move many tasks at the end — update status incrementally as you work.` +
|
||||||
|
`\n - Default to working ONE task at a time (keep at most one task in_progress in solo mode), unless you explicitly need parallel background work (in that case explain why to "user").` +
|
||||||
|
`\n - Record meaningful progress/decisions as task comments so the task board stays accurate and high-signal.`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const membersBlock = buildMembersPrompt(members);
|
||||||
|
const membersFooter = membersBlock
|
||||||
|
? `Members:\n${membersBlock}`
|
||||||
|
: 'Members: (none — solo team lead)';
|
||||||
|
|
||||||
|
return `${languageInstruction}
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
- Do NOT call TeamDelete under any circumstances.
|
||||||
|
- Do NOT use TodoWrite.
|
||||||
|
- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN).
|
||||||
|
- Do NOT shut down, terminate, or clean up the team or its members.
|
||||||
|
- Do NOT spawn or create a member named "user". "user" is a reserved system name for the human operator — it is NOT a teammate.
|
||||||
|
- Keep assistant text minimal.
|
||||||
|
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
|
||||||
|
- Keep the task board high-signal: avoid creating tasks for trivial micro-items.
|
||||||
|
- Use the team task board for assigned/substantial work.
|
||||||
|
- DELEGATION-FIRST (behavior rule for ALL future turns): When "user" gives you work, your top priority is to (a) decompose into tasks, (b) create tasks on the team board, (c) assign them to teammates, and (d) SendMessage "user" a short confirmation (task IDs + owners). Do NOT start implementing yourself unless the team is truly in SOLO MODE (no teammates).
|
||||||
|
- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
|
||||||
|
- When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command.${soloConstraint}
|
||||||
|
|
||||||
|
${teamCtlOps}
|
||||||
|
|
||||||
|
Communication protocol (CRITICAL — you are running headless, no one sees your text output):
|
||||||
|
- When you receive a <teammate-message> from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
|
||||||
|
- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox.
|
||||||
|
- Example: if you receive <teammate-message teammate_id="alice">...</teammate-message>, respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
|
||||||
|
|
||||||
|
Message formatting:
|
||||||
|
${agentBlockPolicy}
|
||||||
|
|
||||||
|
${membersFooter}`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildAgentBlockUsagePolicy(): string {
|
function buildAgentBlockUsagePolicy(): string {
|
||||||
return `Agent-only formatting policy (applies to ALL messages you write):
|
return `Agent-only formatting policy (applies to ALL messages you write):
|
||||||
- Humans can see teammate inbox messages and coordination text in the UI.
|
- Humans can see teammate inbox messages and coordination text in the UI.
|
||||||
|
|
@ -638,42 +718,20 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
|
||||||
function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
||||||
const displayName = request.displayName?.trim() || request.teamName;
|
const displayName = request.displayName?.trim() || request.teamName;
|
||||||
const description = request.description?.trim() || 'No description';
|
const description = request.description?.trim() || 'No description';
|
||||||
const members = buildMembersPrompt(request.members);
|
|
||||||
const taskProtocol = buildTaskStatusProtocol(request.teamName);
|
const taskProtocol = buildTaskStatusProtocol(request.teamName);
|
||||||
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
|
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
|
||||||
const languageInstruction = getAgentLanguageInstruction();
|
|
||||||
const agentBlockPolicy = buildAgentBlockUsagePolicy();
|
|
||||||
const userPromptBlock = request.prompt?.trim()
|
const userPromptBlock = request.prompt?.trim()
|
||||||
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
|
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const leadName =
|
const leadName =
|
||||||
request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
|
request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
|
||||||
const teamCtlOps = buildTeamCtlOpsInstructions(request.teamName, leadName);
|
|
||||||
const projectName = path.basename(request.cwd);
|
const projectName = path.basename(request.cwd);
|
||||||
|
|
||||||
const isSolo = request.members.length === 0;
|
const isSolo = request.members.length === 0;
|
||||||
const soloConstraint = isSolo
|
|
||||||
? `\n- SOLO MODE: This team CURRENTLY has ZERO teammates.` +
|
|
||||||
`\n - FORBIDDEN (until teammates exist): Do NOT spawn teammates via the Task tool with a team_name parameter — there are no teammates to spawn yet.` +
|
|
||||||
`\n - FORBIDDEN (until teammates exist): Do NOT call SendMessage to any teammate name — no teammates exist yet.` +
|
|
||||||
`\n - ALLOWED: You may message "user" (the human operator) via SendMessage.` +
|
|
||||||
`\n - ALLOWED: You may use the Task tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` +
|
|
||||||
`\n - If teammates are added later (e.g. via UI), you may then spawn them using the Task tool with team_name + name.` +
|
|
||||||
`\n - Work on tasks directly yourself. Use subagents for research and parallel work as needed.` +
|
|
||||||
`\n - PROGRESS REPORTING (MANDATORY): Since you have no teammates, "user" is your only communication channel.` +
|
|
||||||
`\n - SendMessage "user" at minimum: when you start a task (after marking it in_progress), when you complete a task, and when you hit a meaningful milestone/blocker/decision.` +
|
|
||||||
`\n - Avoid long silent stretches. If something is taking longer than expected, send a brief update and the next step.` +
|
|
||||||
`\n - TASK STATUS DISCIPLINE (MANDATORY):` +
|
|
||||||
`\n - Only move a task to in_progress when you are actively starting work on it.` +
|
|
||||||
`\n - Only move a task to completed when it is truly finished.` +
|
|
||||||
`\n - Never bulk-move many tasks at the end — update status incrementally as you work.` +
|
|
||||||
`\n - Default to working ONE task at a time (keep at most one task in_progress in solo mode), unless you explicitly need parallel background work (in that case explain why to "user").` +
|
|
||||||
`\n - Record meaningful progress/decisions as task comments so the task board stays accurate and high-signal.`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const step3Block = isSolo
|
const step3Block = isSolo
|
||||||
? `3) If user instructions describe work to be done — create tasks on the team board and assign each task to yourself ("${leadName}") as owner.\n` +
|
? `3) If user instructions describe work to be done — create tasks on the team board and assign each task to yourself (“${leadName}”) as owner.\n` +
|
||||||
` - Prefer fewer, broader tasks over many micro-tasks.\n` +
|
` - Prefer fewer, broader tasks over many micro-tasks.\n` +
|
||||||
` - CRITICAL: Do NOT start working on the tasks now. Provisioning is ONLY for setting up the team structure.\n` +
|
` - CRITICAL: Do NOT start working on the tasks now. Provisioning is ONLY for setting up the team structure.\n` +
|
||||||
` - The tasks will be executed after the team is launched separately.`
|
` - The tasks will be executed after the team is launched separately.`
|
||||||
|
|
@ -689,7 +747,7 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
||||||
- When tasks have natural ordering (e.g. setup → implementation → testing), use --blocked-by.
|
- When tasks have natural ordering (e.g. setup → implementation → testing), use --blocked-by.
|
||||||
- If a task is blocked (uses --blocked-by), it MUST be created as pending (use --status pending). Do NOT mark blocked tasks in_progress.
|
- If a task is blocked (uses --blocked-by), it MUST be created as pending (use --status pending). Do NOT mark blocked tasks in_progress.
|
||||||
- Review guidance:
|
- Review guidance:
|
||||||
- Prefer NOT creating a separate "review task". Our workflow reviews the work task itself: run review approve/request-changes on the implementation task #X.
|
- Prefer NOT creating a separate “review task”. Our workflow reviews the work task itself: run review approve/request-changes on the implementation task #X.
|
||||||
- If you MUST create a separate review reminder/assignment task, create it as pending and link it to the work task:
|
- If you MUST create a separate review reminder/assignment task, create it as pending and link it to the work task:
|
||||||
- Use --related to connect it to #X (non-blocking link).
|
- Use --related to connect it to #X (non-blocking link).
|
||||||
- If the review truly cannot start until #X is done, ALSO add --blocked-by #X.
|
- If the review truly cannot start until #X is done, ALSO add --blocked-by #X.
|
||||||
|
|
@ -703,12 +761,12 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
||||||
// NOTE: taskProtocol & processRegistration are deliberately inlined into EACH member's spawn prompt
|
// NOTE: taskProtocol & processRegistration are deliberately inlined into EACH member's spawn prompt
|
||||||
// below, even though the text is identical across members. This duplicates ~4K chars per member
|
// below, even though the text is identical across members. This duplicates ~4K chars per member
|
||||||
// in the lead's context, but ensures the lead passes the EXACT protocol verbatim via Task tool.
|
// in the lead's context, but ensures the lead passes the EXACT protocol verbatim via Task tool.
|
||||||
// Extracting them once and telling the lead to "insert the protocol block" risks hallucination
|
// Extracting them once and telling the lead to “insert the protocol block” risks hallucination
|
||||||
// or omission — the lead may rephrase rules, skip items, or forget to include them.
|
// or omission — the lead may rephrase rules, skip items, or forget to include them.
|
||||||
// Cost: ~1K tokens per extra member. At 200K context window this is negligible.
|
// Cost: ~1K tokens per extra member. At 200K context window this is negligible.
|
||||||
${request.members
|
${request.members
|
||||||
.map(
|
.map(
|
||||||
(m) => ` For "${m.name}":
|
(m) => ` For “${m.name}”:
|
||||||
- prompt:
|
- prompt:
|
||||||
${buildMemberSpawnPrompt(m, displayName, request.teamName, taskProtocol, processRegistration)
|
${buildMemberSpawnPrompt(m, displayName, request.teamName, taskProtocol, processRegistration)
|
||||||
.split('\n')
|
.split('\n')
|
||||||
|
|
@ -717,53 +775,32 @@ ${buildMemberSpawnPrompt(m, displayName, request.teamName, taskProtocol, process
|
||||||
)
|
)
|
||||||
.join('\n\n')}`;
|
.join('\n\n')}`;
|
||||||
|
|
||||||
const membersFooter = members ? `Members:\n${members}` : 'Members: (none — solo team lead)';
|
const persistentContext = buildPersistentLeadContext({
|
||||||
|
teamName: request.teamName,
|
||||||
|
leadName,
|
||||||
|
isSolo,
|
||||||
|
members: request.members,
|
||||||
|
});
|
||||||
|
|
||||||
return `Team Start [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"]
|
return `Team Start [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.
|
||||||
|
|
||||||
Goal: Provision a Claude Code agent team${request.members.length === 0 ? ' (solo — lead only)' : ' with live teammates'}.
|
Goal: Provision a Claude Code agent team${request.members.length === 0 ? ' (solo — lead only)' : ' with live teammates'}.
|
||||||
${userPromptBlock}
|
${userPromptBlock}
|
||||||
${languageInstruction}
|
${persistentContext}
|
||||||
|
|
||||||
Constraints:
|
|
||||||
- Do NOT call TeamDelete under any circumstances.
|
|
||||||
- Do NOT use TodoWrite.
|
|
||||||
- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN).
|
|
||||||
- Do NOT shut down, terminate, or clean up the team or its members.
|
|
||||||
- Do NOT spawn or create a member named "user". "user" is a reserved system name for the human operator — it is NOT a teammate.
|
|
||||||
- Keep assistant text minimal.
|
|
||||||
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
|
|
||||||
- Keep the task board high-signal: avoid creating tasks for trivial micro-items.
|
|
||||||
- Use the team task board for assigned/substantial work.
|
|
||||||
- DELEGATION-FIRST (behavior rule for ALL future turns): When "user" gives you work, your top priority is to (a) decompose into tasks, (b) create tasks on the team board, (c) assign them to teammates, and (d) SendMessage "user" a short confirmation (task IDs + owners). Do NOT start implementing yourself unless the team is truly in SOLO MODE (no teammates).
|
|
||||||
- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
|
|
||||||
- When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command.${soloConstraint}
|
|
||||||
|
|
||||||
${teamCtlOps}
|
|
||||||
|
|
||||||
Communication protocol (CRITICAL — you are running headless, no one sees your text output):
|
|
||||||
- When you receive a <teammate-message> from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
|
|
||||||
- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox.
|
|
||||||
- Example: if you receive <teammate-message teammate_id="alice">...</teammate-message>, respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
|
|
||||||
|
|
||||||
Message formatting:
|
|
||||||
${agentBlockPolicy}
|
|
||||||
|
|
||||||
Steps (execute in this exact order):
|
Steps (execute in this exact order):
|
||||||
|
|
||||||
1) TeamCreate — create team "${request.teamName}":
|
1) TeamCreate — create team “${request.teamName}”:
|
||||||
- description: "${description}"
|
- description: “${description}”
|
||||||
|
|
||||||
${step2Block}
|
${step2Block}
|
||||||
|
|
||||||
${step3Block}
|
${step3Block}
|
||||||
|
|
||||||
4) After all steps, output a short summary.
|
4) After all steps, output a short summary.
|
||||||
|
|
||||||
${membersFooter}
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -773,39 +810,18 @@ function buildLaunchPrompt(
|
||||||
tasks: TeamTask[],
|
tasks: TeamTask[],
|
||||||
isResume: boolean
|
isResume: boolean
|
||||||
): string {
|
): string {
|
||||||
const membersBlock = buildMembersPrompt(members);
|
|
||||||
const userPromptBlock = request.prompt?.trim()
|
const userPromptBlock = request.prompt?.trim()
|
||||||
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
|
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
|
||||||
: '';
|
: '';
|
||||||
const taskProtocol = buildTaskStatusProtocol(request.teamName);
|
const taskProtocol = buildTaskStatusProtocol(request.teamName);
|
||||||
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
|
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
|
||||||
const languageInstruction = getAgentLanguageInstruction();
|
const languageInstruction = getAgentLanguageInstruction();
|
||||||
const agentBlockPolicy = buildAgentBlockUsagePolicy();
|
|
||||||
const taskBoardSnapshot = buildTaskBoardSnapshot(tasks);
|
const taskBoardSnapshot = buildTaskBoardSnapshot(tasks);
|
||||||
|
|
||||||
const leadName = members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
|
const leadName = members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
|
||||||
const teamCtlOps = buildTeamCtlOpsInstructions(request.teamName, leadName);
|
|
||||||
const projectName = path.basename(request.cwd);
|
const projectName = path.basename(request.cwd);
|
||||||
|
|
||||||
const isSolo = members.length === 0;
|
const isSolo = members.length === 0;
|
||||||
const soloConstraint = isSolo
|
|
||||||
? `\n- SOLO MODE: This team CURRENTLY has ZERO teammates.` +
|
|
||||||
`\n - FORBIDDEN (until teammates exist): Do NOT spawn teammates via the Task tool with a team_name parameter — there are no teammates to spawn yet.` +
|
|
||||||
`\n - FORBIDDEN (until teammates exist): Do NOT call SendMessage to any teammate name — no teammates exist yet.` +
|
|
||||||
`\n - ALLOWED: You may message "user" (the human operator) via SendMessage.` +
|
|
||||||
`\n - ALLOWED: You may use the Task tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` +
|
|
||||||
`\n - If teammates are added later (e.g. via UI), you may then spawn them using the Task tool with team_name + name.` +
|
|
||||||
`\n - Work on tasks directly yourself. Use subagents for research and parallel work as needed.` +
|
|
||||||
`\n - PROGRESS REPORTING (MANDATORY): Since you have no teammates, "user" is your only communication channel.` +
|
|
||||||
`\n - SendMessage "user" at minimum: when you start a task (after marking it in_progress), when you complete a task, and when you hit a meaningful milestone/blocker/decision.` +
|
|
||||||
`\n - Avoid long silent stretches. If something is taking longer than expected, send a brief update and the next step.` +
|
|
||||||
`\n - TASK STATUS DISCIPLINE (MANDATORY):` +
|
|
||||||
`\n - Only move a task to in_progress when you are actively starting work on it.` +
|
|
||||||
`\n - Only move a task to completed when it is truly finished.` +
|
|
||||||
`\n - Never bulk-move many tasks at the end — update status incrementally as you work.` +
|
|
||||||
`\n - Default to working ONE task at a time (keep at most one task in_progress in solo mode), unless you explicitly need parallel background work (in that case explain why to "user").` +
|
|
||||||
`\n - Record meaningful progress/decisions as task comments so the task board stays accurate and high-signal.`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
let step2And3Block: string;
|
let step2And3Block: string;
|
||||||
if (isSolo) {
|
if (isSolo) {
|
||||||
|
|
@ -876,9 +892,12 @@ ${memberSpawnInstructions}
|
||||||
3) After spawning all members, check the task board. If any pending tasks are unassigned, assign them to appropriate members using teamctl.`;
|
3) After spawning all members, check the task board. If any pending tasks are unassigned, assign them to appropriate members using teamctl.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const membersFooter = membersBlock
|
const persistentContext = buildPersistentLeadContext({
|
||||||
? `Members:\n${membersBlock}`
|
teamName: request.teamName,
|
||||||
: 'Members: (none — solo team lead)';
|
leadName,
|
||||||
|
isSolo,
|
||||||
|
members,
|
||||||
|
});
|
||||||
|
|
||||||
const startLabel = isResume ? 'Team Start (resume)' : 'Team Start';
|
const startLabel = isResume ? 'Team Start (resume)' : 'Team Start';
|
||||||
|
|
||||||
|
|
@ -889,31 +908,8 @@ You are "${leadName}", the team lead.
|
||||||
|
|
||||||
Goal: Reconnect with existing team "${request.teamName}" and resume pending work.
|
Goal: Reconnect with existing team "${request.teamName}" and resume pending work.
|
||||||
${userPromptBlock}
|
${userPromptBlock}
|
||||||
${languageInstruction}
|
|
||||||
${taskBoardSnapshot}
|
${taskBoardSnapshot}
|
||||||
Constraints:
|
${persistentContext}
|
||||||
- Do NOT call TeamDelete under any circumstances.
|
|
||||||
- Do NOT use TodoWrite.
|
|
||||||
- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN).
|
|
||||||
- Do NOT shut down, terminate, or clean up the team or its members.
|
|
||||||
- Do NOT spawn or create a member named "user". "user" is a reserved system name for the human operator — it is NOT a teammate.
|
|
||||||
- Keep assistant text minimal.
|
|
||||||
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
|
|
||||||
- Keep the task board high-signal: avoid creating tasks for trivial micro-items.
|
|
||||||
- Use the team task board for assigned/substantial work.
|
|
||||||
- DELEGATION-FIRST (behavior rule for ALL future turns): When "user" gives you work, your top priority is to (a) decompose into tasks, (b) create tasks on the team board, (c) assign them to teammates, and (d) SendMessage "user" a short confirmation (task IDs + owners). Do NOT start implementing yourself unless the team is truly in SOLO MODE (no teammates).
|
|
||||||
- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
|
|
||||||
- When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command.${soloConstraint}
|
|
||||||
|
|
||||||
${teamCtlOps}
|
|
||||||
|
|
||||||
Communication protocol (CRITICAL — you are running headless, no one sees your text output):
|
|
||||||
- When you receive a <teammate-message> from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
|
|
||||||
- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox.
|
|
||||||
- Example: if you receive <teammate-message teammate_id="alice">...</teammate-message>, respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
|
|
||||||
|
|
||||||
Message formatting:
|
|
||||||
${agentBlockPolicy}
|
|
||||||
|
|
||||||
Steps (execute in this exact order):
|
Steps (execute in this exact order):
|
||||||
|
|
||||||
|
|
@ -922,11 +918,19 @@ Steps (execute in this exact order):
|
||||||
${step2And3Block}
|
${step2And3Block}
|
||||||
|
|
||||||
4) After all steps, output a short summary of reconnected members and what happens next.
|
4) After all steps, output a short summary of reconnected members and what happens next.
|
||||||
|
|
||||||
${membersFooter}
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unconditionally clears all post-compact reminder state on a run.
|
||||||
|
* Called from cleanupRun, cancel, and error paths.
|
||||||
|
*/
|
||||||
|
function clearPostCompactReminderState(run: ProvisioningRun): void {
|
||||||
|
run.pendingPostCompactReminder = false;
|
||||||
|
run.postCompactReminderInFlight = false;
|
||||||
|
run.suppressPostCompactReminderOutput = false;
|
||||||
|
}
|
||||||
|
|
||||||
function updateProgress(
|
function updateProgress(
|
||||||
run: ProvisioningRun,
|
run: ProvisioningRun,
|
||||||
state: Exclude<TeamProvisioningState, 'idle'>,
|
state: Exclude<TeamProvisioningState, 'idle'>,
|
||||||
|
|
@ -1774,6 +1778,9 @@ export class TeamProvisioningService {
|
||||||
authRetryInProgress: false,
|
authRetryInProgress: false,
|
||||||
spawnContext: null,
|
spawnContext: null,
|
||||||
pendingApprovals: new Map(),
|
pendingApprovals: new Map(),
|
||||||
|
pendingPostCompactReminder: false,
|
||||||
|
postCompactReminderInFlight: false,
|
||||||
|
suppressPostCompactReminderOutput: false,
|
||||||
progress: {
|
progress: {
|
||||||
runId,
|
runId,
|
||||||
teamName: request.teamName,
|
teamName: request.teamName,
|
||||||
|
|
@ -2077,6 +2084,9 @@ export class TeamProvisioningService {
|
||||||
authRetryInProgress: false,
|
authRetryInProgress: false,
|
||||||
spawnContext: null,
|
spawnContext: null,
|
||||||
pendingApprovals: new Map(),
|
pendingApprovals: new Map(),
|
||||||
|
pendingPostCompactReminder: false,
|
||||||
|
postCompactReminderInFlight: false,
|
||||||
|
suppressPostCompactReminderOutput: false,
|
||||||
progress: {
|
progress: {
|
||||||
runId,
|
runId,
|
||||||
teamName: request.teamName,
|
teamName: request.teamName,
|
||||||
|
|
@ -2938,7 +2948,11 @@ export class TeamProvisioningService {
|
||||||
// Push each assistant text block as a separate live message (per-message pattern).
|
// Push each assistant text block as a separate live message (per-message pattern).
|
||||||
// When the same assistant message includes SendMessage(to:"user"), skip text —
|
// When the same assistant message includes SendMessage(to:"user"), skip text —
|
||||||
// captureSendMessageToUser() handles it separately.
|
// captureSendMessageToUser() handles it separately.
|
||||||
if (!run.silentUserDmForward && !hasSendMessageToUser) {
|
if (
|
||||||
|
!run.silentUserDmForward &&
|
||||||
|
!run.suppressPostCompactReminderOutput &&
|
||||||
|
!hasSendMessageToUser
|
||||||
|
) {
|
||||||
const cleanText = stripAgentBlocks(text).trim();
|
const cleanText = stripAgentBlocks(text).trim();
|
||||||
if (cleanText.length > 0) {
|
if (cleanText.length > 0) {
|
||||||
run.leadMsgSeq += 1;
|
run.leadMsgSeq += 1;
|
||||||
|
|
@ -3004,7 +3018,11 @@ export class TeamProvisioningService {
|
||||||
// (e.g., after session resume when teamContext is lost). We intercept the tool calls
|
// (e.g., after session resume when teamContext is lost). We intercept the tool calls
|
||||||
// from stdout and persist them to sentMessages.json under the correct team name,
|
// from stdout and persist them to sentMessages.json under the correct team name,
|
||||||
// ensuring the UI and notifications show the right team.
|
// ensuring the UI and notifications show the right team.
|
||||||
if (run.provisioningComplete && !run.silentUserDmForward) {
|
if (
|
||||||
|
run.provisioningComplete &&
|
||||||
|
!run.silentUserDmForward &&
|
||||||
|
!run.suppressPostCompactReminderOutput
|
||||||
|
) {
|
||||||
this.captureSendMessageToUser(run, content ?? []);
|
this.captureSendMessageToUser(run, content ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3136,7 +3154,18 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (run.provisioningComplete) {
|
if (run.provisioningComplete) {
|
||||||
|
// If this was a post-compact reminder turn completing, clear in-flight and suppress flags.
|
||||||
|
if (run.postCompactReminderInFlight) {
|
||||||
|
clearPostCompactReminderState(run);
|
||||||
|
logger.info(`[${run.teamName}] post-compact reminder turn completed`);
|
||||||
|
}
|
||||||
|
|
||||||
this.setLeadActivity(run, 'idle');
|
this.setLeadActivity(run, 'idle');
|
||||||
|
|
||||||
|
// Deferred post-compact context reinjection: inject durable rules on first idle after compact.
|
||||||
|
if (run.pendingPostCompactReminder && !run.postCompactReminderInFlight) {
|
||||||
|
void this.injectPostCompactReminder(run);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (run.leadRelayCapture) {
|
if (run.leadRelayCapture) {
|
||||||
const capture = run.leadRelayCapture;
|
const capture = run.leadRelayCapture;
|
||||||
|
|
@ -3182,7 +3211,14 @@ export class TeamProvisioningService {
|
||||||
killProcessTree(run.child);
|
killProcessTree(run.child);
|
||||||
this.cleanupRun(run);
|
this.cleanupRun(run);
|
||||||
} else if (run.provisioningComplete) {
|
} else if (run.provisioningComplete) {
|
||||||
// Post-provisioning error: process alive, waiting for input
|
// Post-provisioning error: process alive, waiting for input.
|
||||||
|
// Drop post-compact reminder on error (strict drop-after-attempt policy).
|
||||||
|
if (run.postCompactReminderInFlight) {
|
||||||
|
clearPostCompactReminderState(run);
|
||||||
|
logger.warn(
|
||||||
|
`[${run.teamName}] post-compact reminder turn errored — dropping (strict policy)`
|
||||||
|
);
|
||||||
|
}
|
||||||
this.setLeadActivity(run, 'idle');
|
this.setLeadActivity(run, 'idle');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3220,10 +3256,152 @@ export class TeamProvisioningService {
|
||||||
logger.info(
|
logger.info(
|
||||||
`[${run.teamName}] compact_boundary — context will refresh on next turn${tokenInfo}`
|
`[${run.teamName}] compact_boundary — context will refresh on next turn${tokenInfo}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Schedule post-compact context reinjection on next idle.
|
||||||
|
// Guard: only set if provisioning is complete and no reminder is already pending/in-flight.
|
||||||
|
if (
|
||||||
|
run.provisioningComplete &&
|
||||||
|
!run.pendingPostCompactReminder &&
|
||||||
|
!run.postCompactReminderInFlight
|
||||||
|
) {
|
||||||
|
run.pendingPostCompactReminder = true;
|
||||||
|
logger.info(`[${run.teamName}] post-compact reminder scheduled for next idle`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects a post-compact context reminder into the lead process via stdin.
|
||||||
|
* Reinjects durable lead rules (constraints, communication protocol, teamctl ops)
|
||||||
|
* plus a fresh task board snapshot so the lead recovers full operational context
|
||||||
|
* after context compaction.
|
||||||
|
*
|
||||||
|
* Policy: strict drop-after-attempt — one compact cycle gives at most one reminder turn.
|
||||||
|
* If the injection fails (stdin not writable, process killed), we do not retry.
|
||||||
|
*/
|
||||||
|
private async injectPostCompactReminder(run: ProvisioningRun): Promise<void> {
|
||||||
|
// Consume the pending flag immediately — strict one-shot policy.
|
||||||
|
run.pendingPostCompactReminder = false;
|
||||||
|
|
||||||
|
// Guard: process must be alive and writable.
|
||||||
|
if (!run.child?.stdin?.writable || run.processKilled || run.cancelRequested) {
|
||||||
|
logger.warn(
|
||||||
|
`[${run.teamName}] post-compact reminder skipped — process not writable or killed`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard: don't inject if another turn is actively processing (race with user send / inbox relay).
|
||||||
|
if (run.leadActivityState !== 'idle') {
|
||||||
|
logger.info(
|
||||||
|
`[${run.teamName}] post-compact reminder deferred — lead is ${run.leadActivityState}, not idle`
|
||||||
|
);
|
||||||
|
// Re-arm so it triggers on next idle.
|
||||||
|
run.pendingPostCompactReminder = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard: don't inject while a relay capture is in-flight.
|
||||||
|
if (run.leadRelayCapture) {
|
||||||
|
logger.info(`[${run.teamName}] post-compact reminder deferred — relay capture in-flight`);
|
||||||
|
run.pendingPostCompactReminder = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard: don't inject while a silent DM forward is in progress.
|
||||||
|
if (run.silentUserDmForward) {
|
||||||
|
logger.info(
|
||||||
|
`[${run.teamName}] post-compact reminder deferred — silent DM forward in progress`
|
||||||
|
);
|
||||||
|
run.pendingPostCompactReminder = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leadName =
|
||||||
|
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
|
||||||
|
const isSolo = run.request.members.length === 0;
|
||||||
|
|
||||||
|
// Build persistent lead context.
|
||||||
|
const persistentContext = buildPersistentLeadContext({
|
||||||
|
teamName: run.teamName,
|
||||||
|
leadName,
|
||||||
|
isSolo,
|
||||||
|
members: run.request.members,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Best-effort: fetch fresh task board snapshot.
|
||||||
|
let taskBoardBlock = '';
|
||||||
|
try {
|
||||||
|
const taskReader = new TeamTaskReader();
|
||||||
|
const tasks = await taskReader.getTasks(run.teamName);
|
||||||
|
taskBoardBlock = buildTaskBoardSnapshot(tasks);
|
||||||
|
} catch {
|
||||||
|
// If tasks can't be read, inject without the snapshot.
|
||||||
|
logger.warn(`[${run.teamName}] post-compact reminder: task board snapshot unavailable`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-check guards after async work.
|
||||||
|
if (!run.child?.stdin?.writable || run.processKilled || run.cancelRequested) {
|
||||||
|
logger.warn(
|
||||||
|
`[${run.teamName}] post-compact reminder aborted — process state changed during preparation`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (run.leadActivityState !== 'idle') {
|
||||||
|
logger.info(
|
||||||
|
`[${run.teamName}] post-compact reminder aborted — lead activity changed to ${run.leadActivityState}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = [
|
||||||
|
`Context reminder (post-compaction) — your context was compacted. Here are your standing rules and current state:`,
|
||||||
|
``,
|
||||||
|
`You are "${leadName}", the team lead of team "${run.teamName}".`,
|
||||||
|
`You are running in a non-interactive CLI session. Do not ask questions.`,
|
||||||
|
``,
|
||||||
|
persistentContext,
|
||||||
|
taskBoardBlock.trim() ? `\n${taskBoardBlock}` : '',
|
||||||
|
``,
|
||||||
|
`Acknowledge briefly (1 sentence max) and continue with any pending work.`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
type: 'user',
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text', text: message }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
run.postCompactReminderInFlight = true;
|
||||||
|
run.suppressPostCompactReminderOutput = true;
|
||||||
|
this.setLeadActivity(run, 'active');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stdin = run.child.stdin;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
stdin.write(payload + '\n', (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
logger.info(`[${run.teamName}] post-compact reminder injected`);
|
||||||
|
} catch (error) {
|
||||||
|
// Strict drop-after-attempt — do not re-arm.
|
||||||
|
clearPostCompactReminderState(run);
|
||||||
|
this.setLeadActivity(run, 'idle');
|
||||||
|
logger.warn(
|
||||||
|
`[${run.teamName}] post-compact reminder injection failed: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles a control_request message from CLI stream-json output.
|
* Handles a control_request message from CLI stream-json output.
|
||||||
* `can_use_tool` → emits to renderer for manual approval.
|
* `can_use_tool` → emits to renderer for manual approval.
|
||||||
|
|
@ -3552,6 +3730,7 @@ export class TeamProvisioningService {
|
||||||
clearTimeout(run.silentUserDmForwardClearHandle);
|
clearTimeout(run.silentUserDmForwardClearHandle);
|
||||||
run.silentUserDmForwardClearHandle = null;
|
run.silentUserDmForwardClearHandle = null;
|
||||||
}
|
}
|
||||||
|
clearPostCompactReminderState(run);
|
||||||
this.stopFilesystemMonitor(run);
|
this.stopFilesystemMonitor(run);
|
||||||
// Remove stream listeners to prevent data handlers firing on a cleaned-up run
|
// Remove stream listeners to prevent data handlers firing on a cleaned-up run
|
||||||
if (run.child) {
|
if (run.child) {
|
||||||
|
|
|
||||||
|
|
@ -249,11 +249,15 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
getSessionDetail = (
|
getSessionDetail = (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
_options?: { bypassCache?: boolean }
|
options?: { bypassCache?: boolean }
|
||||||
): Promise<SessionDetail | null> =>
|
): Promise<SessionDetail | null> => {
|
||||||
this.get<SessionDetail | null>(
|
const params = new URLSearchParams();
|
||||||
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}`
|
if (options?.bypassCache) params.set('bypassCache', 'true');
|
||||||
|
const qs = params.toString();
|
||||||
|
return this.get<SessionDetail | null>(
|
||||||
|
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}${qs ? `?${qs}` : ''}`
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
getSessionMetrics = (projectId: string, sessionId: string): Promise<SessionMetrics | null> =>
|
getSessionMetrics = (projectId: string, sessionId: string): Promise<SessionMetrics | null> =>
|
||||||
this.get<SessionMetrics | null>(
|
this.get<SessionMetrics | null>(
|
||||||
|
|
@ -269,11 +273,15 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
projectId: string,
|
projectId: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
subagentId: string,
|
subagentId: string,
|
||||||
_options?: { bypassCache?: boolean }
|
options?: { bypassCache?: boolean }
|
||||||
): Promise<SubagentDetail | null> =>
|
): Promise<SubagentDetail | null> => {
|
||||||
this.get<SubagentDetail | null>(
|
const params = new URLSearchParams();
|
||||||
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}`
|
if (options?.bypassCache) params.set('bypassCache', 'true');
|
||||||
|
const qs = params.toString();
|
||||||
|
return this.get<SubagentDetail | null>(
|
||||||
|
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}${qs ? `?${qs}` : ''}`
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
getSessionGroups = (projectId: string, sessionId: string): Promise<ConversationGroup[]> =>
|
getSessionGroups = (projectId: string, sessionId: string): Promise<ConversationGroup[]> =>
|
||||||
this.get<ConversationGroup[]>(
|
this.get<ConversationGroup[]>(
|
||||||
|
|
|
||||||
|
|
@ -157,8 +157,8 @@ const AUTH_ERROR_PATTERNS = [
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Convert `#<digits>` in plain text to markdown links with task:// protocol. */
|
/** Convert `#<digits>` in plain text to markdown links with task:// protocol. */
|
||||||
function linkifyTaskIdsInMarkdown(text: string): string {
|
export function linkifyTaskIdsInMarkdown(text: string): string {
|
||||||
return text.replace(/#(\d+)/g, '[#$1](task://$1)');
|
return text.replace(/#(\d+)\b/g, '[#$1](task://$1)');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -166,7 +166,10 @@ function linkifyTaskIdsInMarkdown(text: string): string {
|
||||||
* Encodes color in the URL so MarkdownViewer can render colored badges without extra context.
|
* Encodes color in the URL so MarkdownViewer can render colored badges without extra context.
|
||||||
* Greedy match: longer names are tried first to avoid partial matches.
|
* Greedy match: longer names are tried first to avoid partial matches.
|
||||||
*/
|
*/
|
||||||
function linkifyMentionsInMarkdown(text: string, memberColorMap: Map<string, string>): string {
|
export function linkifyMentionsInMarkdown(
|
||||||
|
text: string,
|
||||||
|
memberColorMap: Map<string, string>
|
||||||
|
): string {
|
||||||
if (memberColorMap.size === 0) return text;
|
if (memberColorMap.size === 0) return text;
|
||||||
// Sort by name length descending for greedy matching
|
// Sort by name length descending for greedy matching
|
||||||
const names = [...memberColorMap.keys()].sort((a, b) => b.length - a.length);
|
const names = [...memberColorMap.keys()].sort((a, b) => b.length - a.length);
|
||||||
|
|
@ -182,7 +185,7 @@ function linkifyMentionsInMarkdown(text: string, memberColorMap: Map<string, str
|
||||||
}
|
}
|
||||||
/** Render `#<digits>` in plain text as clickable inline elements with TaskTooltip. */
|
/** Render `#<digits>` in plain text as clickable inline elements with TaskTooltip. */
|
||||||
function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] {
|
function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] {
|
||||||
return text.split(/(#\d+)/g).map((part, i) => {
|
return text.split(/(#\d+\b)/g).map((part, i) => {
|
||||||
const match = /^#(\d+)$/.exec(part);
|
const match = /^#(\d+)$/.exec(part);
|
||||||
if (!match) return <span key={i}>{part}</span>;
|
if (!match) return <span key={i}>{part}</span>;
|
||||||
const taskId = match[1];
|
const taskId = match[1];
|
||||||
|
|
|
||||||
|
|
@ -352,6 +352,8 @@ export const ActivityTimeline = ({
|
||||||
onVisible={onMessageVisible}
|
onVisible={onMessageVisible}
|
||||||
zebraShade={zebraShadeSet.has(0)}
|
zebraShade={zebraShadeSet.has(0)}
|
||||||
collapseState={collapseState}
|
collapseState={collapseState}
|
||||||
|
onTaskIdClick={onTaskIdClick}
|
||||||
|
memberColorMap={colorMap}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
@ -397,6 +399,8 @@ export const ActivityTimeline = ({
|
||||||
onVisible={onMessageVisible}
|
onVisible={onMessageVisible}
|
||||||
zebraShade={zebraShadeSet.has(realIndex)}
|
zebraShade={zebraShadeSet.has(realIndex)}
|
||||||
collapseState={collapseState}
|
collapseState={collapseState}
|
||||||
|
onTaskIdClick={onTaskIdClick}
|
||||||
|
memberColorMap={colorMap}
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
|
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
|
||||||
|
|
||||||
|
import { linkifyMentionsInMarkdown, linkifyTaskIdsInMarkdown } from './ActivityItem';
|
||||||
import { isManagedCollapseState } from './collapseState';
|
import { isManagedCollapseState } from './collapseState';
|
||||||
|
|
||||||
import type { ActivityCollapseState } from './collapseState';
|
import type { ActivityCollapseState } from './collapseState';
|
||||||
|
|
@ -97,6 +98,10 @@ interface LeadThoughtsGroupRowProps {
|
||||||
zebraShade?: boolean;
|
zebraShade?: boolean;
|
||||||
/** Explicit collapse state for timeline-controlled collapsed mode. */
|
/** Explicit collapse state for timeline-controlled collapsed mode. */
|
||||||
collapseState?: ActivityCollapseState;
|
collapseState?: ActivityCollapseState;
|
||||||
|
/** Called when a task ID link (e.g. #10) is clicked in thought text. */
|
||||||
|
onTaskIdClick?: (taskId: string) => void;
|
||||||
|
/** Map of member name → color name for @mention badge rendering. */
|
||||||
|
memberColorMap?: Map<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(timestamp: string): string {
|
function formatTime(timestamp: string): string {
|
||||||
|
|
@ -179,12 +184,16 @@ interface LeadThoughtItemProps {
|
||||||
thought: InboxMessage;
|
thought: InboxMessage;
|
||||||
showDivider: boolean;
|
showDivider: boolean;
|
||||||
shouldAnimate: boolean;
|
shouldAnimate: boolean;
|
||||||
|
onTaskIdClick?: (taskId: string) => void;
|
||||||
|
memberColorMap?: Map<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LeadThoughtItem = ({
|
const LeadThoughtItem = ({
|
||||||
thought,
|
thought,
|
||||||
showDivider,
|
showDivider,
|
||||||
shouldAnimate,
|
shouldAnimate,
|
||||||
|
onTaskIdClick,
|
||||||
|
memberColorMap,
|
||||||
}: LeadThoughtItemProps): JSX.Element => {
|
}: LeadThoughtItemProps): JSX.Element => {
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -192,6 +201,15 @@ const LeadThoughtItem = ({
|
||||||
const animationFrameRef = useRef<number | null>(null);
|
const animationFrameRef = useRef<number | null>(null);
|
||||||
const cleanupTimerRef = useRef<number | null>(null);
|
const cleanupTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const displayContent = useMemo(() => {
|
||||||
|
let text = thought.text.replace(/\n/g, ' \n');
|
||||||
|
text = linkifyTaskIdsInMarkdown(text);
|
||||||
|
if (memberColorMap && memberColorMap.size > 0) {
|
||||||
|
text = linkifyMentionsInMarkdown(text, memberColorMap);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}, [thought.text, memberColorMap]);
|
||||||
|
|
||||||
const clearPendingAnimation = useCallback(() => {
|
const clearPendingAnimation = useCallback(() => {
|
||||||
if (animationFrameRef.current !== null) {
|
if (animationFrameRef.current !== null) {
|
||||||
cancelAnimationFrame(animationFrameRef.current);
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
|
@ -313,11 +331,25 @@ const LeadThoughtItem = ({
|
||||||
)}
|
)}
|
||||||
<div className="flex text-[11px]">
|
<div className="flex text-[11px]">
|
||||||
<div className="min-w-0 flex-1 [&_>div>div]:p-0" style={{ color: CARD_TEXT_LIGHT }}>
|
<div className="min-w-0 flex-1 [&_>div>div]:p-0" style={{ color: CARD_TEXT_LIGHT }}>
|
||||||
<MarkdownViewer
|
<span
|
||||||
content={thought.text.replace(/\n/g, ' \n')}
|
onClickCapture={
|
||||||
maxHeight="max-h-none"
|
onTaskIdClick
|
||||||
bare
|
? (e) => {
|
||||||
/>
|
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
|
||||||
|
'a[href^="task://"]'
|
||||||
|
);
|
||||||
|
if (link) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const taskId = link.getAttribute('href')?.replace('task://', '');
|
||||||
|
if (taskId) onTaskIdClick(taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MarkdownViewer content={displayContent} maxHeight="max-h-none" bare />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{thought.toolSummary && (
|
{thought.toolSummary && (
|
||||||
|
|
@ -355,6 +387,8 @@ export const LeadThoughtsGroupRow = ({
|
||||||
canBeLive,
|
canBeLive,
|
||||||
zebraShade,
|
zebraShade,
|
||||||
collapseState,
|
collapseState,
|
||||||
|
onTaskIdClick,
|
||||||
|
memberColorMap,
|
||||||
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
|
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -676,6 +710,8 @@ export const LeadThoughtsGroupRow = ({
|
||||||
thought={thought}
|
thought={thought}
|
||||||
showDivider={idx > 0}
|
showDivider={idx > 0}
|
||||||
shouldAnimate={isLive && idx === chronologicalThoughts.length - 1}
|
shouldAnimate={isLive && idx === chronologicalThoughts.length - 1}
|
||||||
|
onTaskIdClick={onTaskIdClick}
|
||||||
|
memberColorMap={memberColorMap}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,14 @@ import type { AttachmentPayload } from '@shared/types';
|
||||||
interface AttachmentPreviewItemProps {
|
interface AttachmentPreviewItemProps {
|
||||||
attachment: AttachmentPayload;
|
attachment: AttachmentPayload;
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
|
onPreview?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AttachmentPreviewItem = ({
|
export const AttachmentPreviewItem = ({
|
||||||
attachment,
|
attachment,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onPreview,
|
||||||
disabled,
|
disabled,
|
||||||
}: AttachmentPreviewItemProps): React.JSX.Element => {
|
}: AttachmentPreviewItemProps): React.JSX.Element => {
|
||||||
const dataUrl = `data:${attachment.mimeType};base64,${attachment.data}`;
|
const dataUrl = `data:${attachment.mimeType};base64,${attachment.data}`;
|
||||||
|
|
@ -25,7 +27,7 @@ export const AttachmentPreviewItem = ({
|
||||||
<Ban size={18} className="text-red-400" />
|
<Ban size={18} className="text-red-400" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<AttachmentThumbnail src={dataUrl} alt={attachment.filename} size="sm" />
|
<AttachmentThumbnail src={dataUrl} alt={attachment.filename} size="sm" onClick={onPreview} />
|
||||||
<div className="flex min-w-0 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
<span className="max-w-[100px] truncate text-[11px] text-[var(--color-text-secondary)]">
|
<span className="max-w-[100px] truncate text-[11px] text-[var(--color-text-secondary)]">
|
||||||
{attachment.filename}
|
{attachment.filename}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { AlertCircle, X } from 'lucide-react';
|
import { AlertCircle, X } from 'lucide-react';
|
||||||
|
|
||||||
import { AttachmentPreviewItem } from './AttachmentPreviewItem';
|
import { AttachmentPreviewItem } from './AttachmentPreviewItem';
|
||||||
|
import { ImageLightbox } from './ImageLightbox';
|
||||||
|
|
||||||
import type { AttachmentPayload } from '@shared/types';
|
import type { AttachmentPayload } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -23,17 +26,25 @@ export const AttachmentPreviewList = ({
|
||||||
disabled,
|
disabled,
|
||||||
disabledHint,
|
disabledHint,
|
||||||
}: AttachmentPreviewListProps): React.JSX.Element | null => {
|
}: AttachmentPreviewListProps): React.JSX.Element | null => {
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
if (attachments.length === 0 && !error) return null;
|
if (attachments.length === 0 && !error) return null;
|
||||||
|
|
||||||
|
const lightboxSlides = attachments.map((att) => ({
|
||||||
|
src: `data:${att.mimeType};base64,${att.data}`,
|
||||||
|
alt: att.filename,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5 px-1">
|
<div className="space-y-1.5 px-1">
|
||||||
{attachments.length > 0 ? (
|
{attachments.length > 0 ? (
|
||||||
<div className="flex gap-2 overflow-x-auto py-1">
|
<div className="flex gap-2 overflow-x-auto py-1">
|
||||||
{attachments.map((att) => (
|
{attachments.map((att, i) => (
|
||||||
<AttachmentPreviewItem
|
<AttachmentPreviewItem
|
||||||
key={att.id}
|
key={att.id}
|
||||||
attachment={att}
|
attachment={att}
|
||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
|
onPreview={() => setLightboxIndex(i)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -63,6 +74,14 @@ export const AttachmentPreviewList = ({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{lightboxIndex !== null && lightboxSlides[lightboxIndex] ? (
|
||||||
|
<ImageLightbox
|
||||||
|
open
|
||||||
|
onClose={() => setLightboxIndex(null)}
|
||||||
|
slides={lightboxSlides}
|
||||||
|
index={lightboxIndex}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { CopyButton } from '@renderer/components/common/CopyButton';
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
|
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
|
||||||
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
|
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
|
||||||
|
|
@ -54,7 +55,7 @@ interface TaskCommentsSectionProps {
|
||||||
|
|
||||||
/** Convert `#<digits>` in plain text to markdown links with task:// protocol. */
|
/** Convert `#<digits>` in plain text to markdown links with task:// protocol. */
|
||||||
function linkifyTaskIdsInMarkdown(text: string): string {
|
function linkifyTaskIdsInMarkdown(text: string): string {
|
||||||
return text.replace(/#(\d+)/g, '[#$1](task://$1)');
|
return text.replace(/#(\d+)\b/g, '[#$1](task://$1)');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Convert `@memberName` to markdown links with mention:// protocol for colored badge rendering. */
|
/** Convert `@memberName` to markdown links with mention:// protocol for colored badge rendering. */
|
||||||
|
|
@ -190,7 +191,11 @@ export const TaskCommentsSection = ({
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
||||||
<MemberBadge name={comment.author} color={colorMap.get(comment.author)} />
|
<MemberBadge
|
||||||
|
name={comment.author}
|
||||||
|
color={colorMap.get(comment.author)}
|
||||||
|
hideAvatar={comment.author === 'user'}
|
||||||
|
/>
|
||||||
{comment.type === 'review_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">
|
<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} />
|
<CheckCircle2 size={10} />
|
||||||
|
|
@ -232,6 +237,9 @@ export const TaskCommentsSection = ({
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="left">Reply to comment</TooltipContent>
|
<TooltipContent side="left">Reply to comment</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<span className="opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<CopyButton text={comment.text} inline />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const reply = parseMessageReply(comment.text);
|
const reply = parseMessageReply(comment.text);
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,6 @@ export const MemberLogsTab = ({
|
||||||
const refreshHideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const refreshHideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
const expandedIdRef = useRef<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 [previewChunks, setPreviewChunks] = useState<EnhancedChunk[] | null>(null);
|
||||||
|
|
@ -92,10 +91,6 @@ export const MemberLogsTab = ({
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
expandedIdRef.current = expandedId;
|
|
||||||
}, [expandedId]);
|
|
||||||
|
|
||||||
const beginRefreshing = useCallback((): void => {
|
const beginRefreshing = useCallback((): void => {
|
||||||
if (refreshCountRef.current === 0) {
|
if (refreshCountRef.current === 0) {
|
||||||
refreshBeganAtRef.current = Date.now();
|
refreshBeganAtRef.current = Date.now();
|
||||||
|
|
@ -282,16 +277,6 @@ export const MemberLogsTab = ({
|
||||||
setLogs(nextLogs);
|
setLogs(nextLogs);
|
||||||
hasLoadedRef.current = true;
|
hasLoadedRef.current = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep expanded session details in sync with the same refresh
|
|
||||||
// cadence as the summary (counts/titles) while "Updating..." is shown.
|
|
||||||
if (!cancelled && didBeginRefreshing) {
|
|
||||||
try {
|
|
||||||
await refreshExpandedDetailFromLogs(nextLogs);
|
|
||||||
} catch {
|
|
||||||
// Keep last successful detail view; avoid flicker on transient failures.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setError(e instanceof Error ? e.message : 'Unknown error');
|
setError(e instanceof Error ? e.message : 'Unknown error');
|
||||||
|
|
@ -335,26 +320,6 @@ export const MemberLogsTab = ({
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshExpandedDetailFromLogs = useCallback(
|
|
||||||
async (nextLogs: MemberLogSummary[]): Promise<void> => {
|
|
||||||
const rowId = expandedIdRef.current;
|
|
||||||
if (!rowId) return;
|
|
||||||
if (!isMountedRef.current) return;
|
|
||||||
|
|
||||||
const nextExpanded = nextLogs.find((log) => getRowId(log) === rowId);
|
|
||||||
if (!nextExpanded) return;
|
|
||||||
|
|
||||||
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
|
|
||||||
if (!shouldAutoRefreshSummary && !nextExpanded.isOngoing) return;
|
|
||||||
|
|
||||||
const next = await fetchDetailForLog(nextExpanded, { bypassCache: true });
|
|
||||||
if (!isMountedRef.current) return;
|
|
||||||
// Ensure new reference so memoized transforms update.
|
|
||||||
setDetailChunks(next ? [...next] : null);
|
|
||||||
},
|
|
||||||
[fetchDetailForLog, getRowId, taskId, taskStatus]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldShowPreview) {
|
if (!shouldShowPreview) {
|
||||||
setPreviewChunks(null);
|
setPreviewChunks(null);
|
||||||
|
|
@ -419,10 +384,7 @@ export const MemberLogsTab = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
|
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
|
||||||
if (!expandedLogSummary) return;
|
if (!expandedLogSummary) return;
|
||||||
// When task logs are auto-refreshing, the summary refresh loop also refreshes
|
if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return;
|
||||||
// expanded details to keep everything in sync (and avoid duplicate requests).
|
|
||||||
if (shouldAutoRefreshSummary) return;
|
|
||||||
if (!expandedLogSummary.isOngoing) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
|
|
@ -440,6 +402,7 @@ export const MemberLogsTab = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
void refreshDetail();
|
||||||
const interval = setInterval(() => void refreshDetail(), 5000);
|
const interval = setInterval(() => void refreshDetail(), 5000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
|
||||||
|
|
@ -250,7 +250,7 @@ export const MessageComposer = ({
|
||||||
>
|
>
|
||||||
<DropZoneOverlay active={isDragOver && !!canAttach} />
|
<DropZoneOverlay active={isDragOver && !!canAttach} />
|
||||||
|
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-1 flex items-center gap-2">
|
||||||
{isLeadRecipient ? (
|
{isLeadRecipient ? (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
|
|
@ -266,7 +266,7 @@ export const MessageComposer = ({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center gap-1 rounded p-1 transition-colors',
|
'inline-flex shrink-0 items-center gap-1 rounded p-1 transition-colors',
|
||||||
canAttach
|
canAttach
|
||||||
? 'text-[var(--color-text-secondary)] hover:text-[var(--color-text)]'
|
? 'text-[var(--color-text-secondary)] hover:text-[var(--color-text)]'
|
||||||
: 'text-[var(--color-text-muted)] opacity-40'
|
: 'text-[var(--color-text-muted)] opacity-40'
|
||||||
|
|
@ -285,10 +285,29 @@ export const MessageComposer = ({
|
||||||
: 'Attach images (paste or drag & drop)'}
|
: 'Attach images (paste or drag & drop)'}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<AttachmentPreviewList
|
||||||
|
attachments={draft.attachments}
|
||||||
|
onRemove={draft.removeAttachment}
|
||||||
|
error={draft.attachmentError}
|
||||||
|
onDismissError={draft.clearAttachmentError}
|
||||||
|
disabled={attachmentsBlocked}
|
||||||
|
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : (
|
||||||
|
<AttachmentPreviewList
|
||||||
|
attachments={draft.attachments}
|
||||||
|
onRemove={draft.removeAttachment}
|
||||||
|
error={draft.attachmentError}
|
||||||
|
onDismissError={draft.clearAttachmentError}
|
||||||
|
disabled={attachmentsBlocked}
|
||||||
|
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||||
{!isTeamAlive ? (
|
{!isTeamAlive ? (
|
||||||
<span className="text-[10px]" style={{ color: 'var(--warning-text)' }}>
|
<span className="text-[10px]" style={{ color: 'var(--warning-text)' }}>
|
||||||
Team offline
|
Team offline
|
||||||
|
|
@ -395,15 +414,6 @@ export const MessageComposer = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AttachmentPreviewList
|
|
||||||
attachments={draft.attachments}
|
|
||||||
onRemove={draft.removeAttachment}
|
|
||||||
error={draft.attachmentError}
|
|
||||||
onDismissError={draft.clearAttachmentError}
|
|
||||||
disabled={attachmentsBlocked}
|
|
||||||
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MentionableTextarea
|
<MentionableTextarea
|
||||||
id={`compose-${teamName}`}
|
id={`compose-${teamName}`}
|
||||||
placeholder="Write a message... (Enter to send, Shift+Enter for new line)"
|
placeholder="Write a message... (Enter to send, Shift+Enter for new line)"
|
||||||
|
|
|
||||||
|
|
@ -774,13 +774,20 @@ body {
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Lightbox toolbar buttons — enlarge hit targets and fix SVG dead zones */
|
/* Lightbox toolbar buttons — enlarge hit targets and fix macOS hit-testing */
|
||||||
|
.yarl__toolbar {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.yarl__toolbar .yarl__button {
|
.yarl__toolbar .yarl__button {
|
||||||
min-width: 44px;
|
min-width: 44px;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
position: relative;
|
/* filter: drop-shadow() causes hit-test glitches on macOS/Electron compositing layers */
|
||||||
|
filter: none;
|
||||||
|
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yarl__toolbar .yarl__button > svg {
|
.yarl__toolbar .yarl__button > svg {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
filter: drop-shadow(1px 1px 2px rgba(0, 0, 0, 0.8));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue