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">
|
||||
<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 align="center">
|
||||
|
|
|
|||
|
|
@ -137,78 +137,76 @@ export function registerSessionRoutes(app: FastifyInstance, services: HttpServic
|
|||
);
|
||||
|
||||
// Session detail
|
||||
app.get<{ Params: { projectId: string; sessionId: string } }>(
|
||||
'/api/projects/:projectId/sessions/:sessionId',
|
||||
async (request) => {
|
||||
try {
|
||||
const validatedProject = validateProjectId(request.params.projectId);
|
||||
const validatedSession = validateSessionId(request.params.sessionId);
|
||||
if (!validatedProject.valid || !validatedSession.valid) {
|
||||
logger.error(
|
||||
`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) {
|
||||
app.get<{
|
||||
Params: { projectId: string; sessionId: string };
|
||||
Querystring: { bypassCache?: string };
|
||||
}>('/api/projects/:projectId/sessions/:sessionId', async (request) => {
|
||||
try {
|
||||
const validatedProject = validateProjectId(request.params.projectId);
|
||||
const validatedSession = validateSessionId(request.params.sessionId);
|
||||
if (!validatedProject.valid || !validatedSession.valid) {
|
||||
logger.error(
|
||||
`Error in GET session-detail for ${request.params.projectId}/${request.params.sessionId}:`,
|
||||
error
|
||||
`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);
|
||||
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
|
||||
app.get<{ Params: { projectId: string; sessionId: string } }>(
|
||||
|
|
|
|||
|
|
@ -15,63 +15,64 @@ import type { FastifyInstance } from 'fastify';
|
|||
const logger = createLogger('HTTP:subagents');
|
||||
|
||||
export function registerSubagentRoutes(app: FastifyInstance, services: HttpServices): void {
|
||||
app.get<{ Params: { projectId: string; sessionId: string; subagentId: string } }>(
|
||||
'/api/projects/:projectId/sessions/:sessionId/subagents/:subagentId',
|
||||
async (request) => {
|
||||
try {
|
||||
const validatedProject = validateProjectId(request.params.projectId);
|
||||
const validatedSession = validateSessionId(request.params.sessionId);
|
||||
const validatedSubagent = validateSubagentId(request.params.subagentId);
|
||||
if (!validatedProject.valid || !validatedSession.valid || !validatedSubagent.valid) {
|
||||
logger.error(
|
||||
`GET subagent-detail rejected: ${
|
||||
validatedProject.error ??
|
||||
validatedSession.error ??
|
||||
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
|
||||
app.get<{
|
||||
Params: { projectId: string; sessionId: string; subagentId: string };
|
||||
Querystring: { bypassCache?: string };
|
||||
}>('/api/projects/:projectId/sessions/:sessionId/subagents/:subagentId', async (request) => {
|
||||
try {
|
||||
const validatedProject = validateProjectId(request.params.projectId);
|
||||
const validatedSession = validateSessionId(request.params.sessionId);
|
||||
const validatedSubagent = validateSubagentId(request.params.subagentId);
|
||||
if (!validatedProject.valid || !validatedSession.valid || !validatedSubagent.valid) {
|
||||
logger.error(
|
||||
`GET subagent-detail rejected: ${
|
||||
validatedProject.error ??
|
||||
validatedSession.error ??
|
||||
validatedSubagent.error ??
|
||||
'Invalid parameters'
|
||||
}`
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
/** Pending tool approval requests awaiting user response (control_request protocol). */
|
||||
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';
|
||||
|
|
@ -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 {
|
||||
return `Agent-only formatting policy (applies to ALL messages you write):
|
||||
- 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 {
|
||||
const displayName = request.displayName?.trim() || request.teamName;
|
||||
const description = request.description?.trim() || 'No description';
|
||||
const members = buildMembersPrompt(request.members);
|
||||
const taskProtocol = buildTaskStatusProtocol(request.teamName);
|
||||
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
|
||||
const languageInstruction = getAgentLanguageInstruction();
|
||||
const agentBlockPolicy = buildAgentBlockUsagePolicy();
|
||||
const userPromptBlock = request.prompt?.trim()
|
||||
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
|
||||
: '';
|
||||
|
||||
const leadName =
|
||||
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 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
|
||||
? `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` +
|
||||
` - 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.`
|
||||
|
|
@ -689,7 +747,7 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
|||
- 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.
|
||||
- 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:
|
||||
- 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.
|
||||
|
|
@ -703,12 +761,12 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
|||
// 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
|
||||
// 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.
|
||||
// Cost: ~1K tokens per extra member. At 200K context window this is negligible.
|
||||
${request.members
|
||||
.map(
|
||||
(m) => ` For "${m.name}":
|
||||
(m) => ` For “${m.name}”:
|
||||
- prompt:
|
||||
${buildMemberSpawnPrompt(m, displayName, request.teamName, taskProtocol, processRegistration)
|
||||
.split('\n')
|
||||
|
|
@ -717,53 +775,32 @@ ${buildMemberSpawnPrompt(m, displayName, request.teamName, taskProtocol, process
|
|||
)
|
||||
.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 "${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'}.
|
||||
${userPromptBlock}
|
||||
${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}
|
||||
${persistentContext}
|
||||
|
||||
Steps (execute in this exact order):
|
||||
|
||||
1) TeamCreate — create team "${request.teamName}":
|
||||
- description: "${description}"
|
||||
1) TeamCreate — create team “${request.teamName}”:
|
||||
- description: “${description}”
|
||||
|
||||
${step2Block}
|
||||
|
||||
${step3Block}
|
||||
|
||||
4) After all steps, output a short summary.
|
||||
|
||||
${membersFooter}
|
||||
`;
|
||||
}
|
||||
|
||||
|
|
@ -773,39 +810,18 @@ function buildLaunchPrompt(
|
|||
tasks: TeamTask[],
|
||||
isResume: boolean
|
||||
): string {
|
||||
const membersBlock = buildMembersPrompt(members);
|
||||
const userPromptBlock = request.prompt?.trim()
|
||||
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
|
||||
: '';
|
||||
const taskProtocol = buildTaskStatusProtocol(request.teamName);
|
||||
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
|
||||
const languageInstruction = getAgentLanguageInstruction();
|
||||
const agentBlockPolicy = buildAgentBlockUsagePolicy();
|
||||
const taskBoardSnapshot = buildTaskBoardSnapshot(tasks);
|
||||
|
||||
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 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;
|
||||
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.`;
|
||||
}
|
||||
|
||||
const membersFooter = membersBlock
|
||||
? `Members:\n${membersBlock}`
|
||||
: 'Members: (none — solo team lead)';
|
||||
const persistentContext = buildPersistentLeadContext({
|
||||
teamName: request.teamName,
|
||||
leadName,
|
||||
isSolo,
|
||||
members,
|
||||
});
|
||||
|
||||
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.
|
||||
${userPromptBlock}
|
||||
${languageInstruction}
|
||||
${taskBoardSnapshot}
|
||||
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}
|
||||
${persistentContext}
|
||||
|
||||
Steps (execute in this exact order):
|
||||
|
||||
|
|
@ -922,11 +918,19 @@ Steps (execute in this exact order):
|
|||
${step2And3Block}
|
||||
|
||||
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(
|
||||
run: ProvisioningRun,
|
||||
state: Exclude<TeamProvisioningState, 'idle'>,
|
||||
|
|
@ -1774,6 +1778,9 @@ export class TeamProvisioningService {
|
|||
authRetryInProgress: false,
|
||||
spawnContext: null,
|
||||
pendingApprovals: new Map(),
|
||||
pendingPostCompactReminder: false,
|
||||
postCompactReminderInFlight: false,
|
||||
suppressPostCompactReminderOutput: false,
|
||||
progress: {
|
||||
runId,
|
||||
teamName: request.teamName,
|
||||
|
|
@ -2077,6 +2084,9 @@ export class TeamProvisioningService {
|
|||
authRetryInProgress: false,
|
||||
spawnContext: null,
|
||||
pendingApprovals: new Map(),
|
||||
pendingPostCompactReminder: false,
|
||||
postCompactReminderInFlight: false,
|
||||
suppressPostCompactReminderOutput: false,
|
||||
progress: {
|
||||
runId,
|
||||
teamName: request.teamName,
|
||||
|
|
@ -2938,7 +2948,11 @@ export class TeamProvisioningService {
|
|||
// Push each assistant text block as a separate live message (per-message pattern).
|
||||
// When the same assistant message includes SendMessage(to:"user"), skip text —
|
||||
// captureSendMessageToUser() handles it separately.
|
||||
if (!run.silentUserDmForward && !hasSendMessageToUser) {
|
||||
if (
|
||||
!run.silentUserDmForward &&
|
||||
!run.suppressPostCompactReminderOutput &&
|
||||
!hasSendMessageToUser
|
||||
) {
|
||||
const cleanText = stripAgentBlocks(text).trim();
|
||||
if (cleanText.length > 0) {
|
||||
run.leadMsgSeq += 1;
|
||||
|
|
@ -3004,7 +3018,11 @@ export class TeamProvisioningService {
|
|||
// (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,
|
||||
// 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 ?? []);
|
||||
}
|
||||
|
||||
|
|
@ -3136,7 +3154,18 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
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');
|
||||
|
||||
// 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) {
|
||||
const capture = run.leadRelayCapture;
|
||||
|
|
@ -3182,7 +3211,14 @@ export class TeamProvisioningService {
|
|||
killProcessTree(run.child);
|
||||
this.cleanupRun(run);
|
||||
} 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -3220,10 +3256,152 @@ export class TeamProvisioningService {
|
|||
logger.info(
|
||||
`[${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.
|
||||
* `can_use_tool` → emits to renderer for manual approval.
|
||||
|
|
@ -3552,6 +3730,7 @@ export class TeamProvisioningService {
|
|||
clearTimeout(run.silentUserDmForwardClearHandle);
|
||||
run.silentUserDmForwardClearHandle = null;
|
||||
}
|
||||
clearPostCompactReminderState(run);
|
||||
this.stopFilesystemMonitor(run);
|
||||
// Remove stream listeners to prevent data handlers firing on a cleaned-up run
|
||||
if (run.child) {
|
||||
|
|
|
|||
|
|
@ -249,11 +249,15 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
getSessionDetail = (
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
_options?: { bypassCache?: boolean }
|
||||
): Promise<SessionDetail | null> =>
|
||||
this.get<SessionDetail | null>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}`
|
||||
options?: { bypassCache?: boolean }
|
||||
): Promise<SessionDetail | null> => {
|
||||
const params = new URLSearchParams();
|
||||
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> =>
|
||||
this.get<SessionMetrics | null>(
|
||||
|
|
@ -269,11 +273,15 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
projectId: string,
|
||||
sessionId: string,
|
||||
subagentId: string,
|
||||
_options?: { bypassCache?: boolean }
|
||||
): Promise<SubagentDetail | null> =>
|
||||
this.get<SubagentDetail | null>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}`
|
||||
options?: { bypassCache?: boolean }
|
||||
): Promise<SubagentDetail | null> => {
|
||||
const params = new URLSearchParams();
|
||||
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[]> =>
|
||||
this.get<ConversationGroup[]>(
|
||||
|
|
|
|||
|
|
@ -157,8 +157,8 @@ const AUTH_ERROR_PATTERNS = [
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Convert `#<digits>` in plain text to markdown links with task:// protocol. */
|
||||
function linkifyTaskIdsInMarkdown(text: string): string {
|
||||
return text.replace(/#(\d+)/g, '[#$1](task://$1)');
|
||||
export function linkifyTaskIdsInMarkdown(text: string): string {
|
||||
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.
|
||||
* 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;
|
||||
// Sort by name length descending for greedy matching
|
||||
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. */
|
||||
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);
|
||||
if (!match) return <span key={i}>{part}</span>;
|
||||
const taskId = match[1];
|
||||
|
|
|
|||
|
|
@ -352,6 +352,8 @@ export const ActivityTimeline = ({
|
|||
onVisible={onMessageVisible}
|
||||
zebraShade={zebraShadeSet.has(0)}
|
||||
collapseState={collapseState}
|
||||
onTaskIdClick={onTaskIdClick}
|
||||
memberColorMap={colorMap}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
|
@ -397,6 +399,8 @@ export const ActivityTimeline = ({
|
|||
onVisible={onMessageVisible}
|
||||
zebraShade={zebraShadeSet.has(realIndex)}
|
||||
collapseState={collapseState}
|
||||
onTaskIdClick={onTaskIdClick}
|
||||
memberColorMap={colorMap}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
|||
import { useStore } from '@renderer/store';
|
||||
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
|
||||
|
||||
import { linkifyMentionsInMarkdown, linkifyTaskIdsInMarkdown } from './ActivityItem';
|
||||
import { isManagedCollapseState } from './collapseState';
|
||||
|
||||
import type { ActivityCollapseState } from './collapseState';
|
||||
|
|
@ -97,6 +98,10 @@ interface LeadThoughtsGroupRowProps {
|
|||
zebraShade?: boolean;
|
||||
/** Explicit collapse state for timeline-controlled collapsed mode. */
|
||||
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 {
|
||||
|
|
@ -179,12 +184,16 @@ interface LeadThoughtItemProps {
|
|||
thought: InboxMessage;
|
||||
showDivider: boolean;
|
||||
shouldAnimate: boolean;
|
||||
onTaskIdClick?: (taskId: string) => void;
|
||||
memberColorMap?: Map<string, string>;
|
||||
}
|
||||
|
||||
const LeadThoughtItem = ({
|
||||
thought,
|
||||
showDivider,
|
||||
shouldAnimate,
|
||||
onTaskIdClick,
|
||||
memberColorMap,
|
||||
}: LeadThoughtItemProps): JSX.Element => {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -192,6 +201,15 @@ const LeadThoughtItem = ({
|
|||
const animationFrameRef = 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(() => {
|
||||
if (animationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
|
|
@ -313,11 +331,25 @@ const LeadThoughtItem = ({
|
|||
)}
|
||||
<div className="flex text-[11px]">
|
||||
<div className="min-w-0 flex-1 [&_>div>div]:p-0" style={{ color: CARD_TEXT_LIGHT }}>
|
||||
<MarkdownViewer
|
||||
content={thought.text.replace(/\n/g, ' \n')}
|
||||
maxHeight="max-h-none"
|
||||
bare
|
||||
/>
|
||||
<span
|
||||
onClickCapture={
|
||||
onTaskIdClick
|
||||
? (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>
|
||||
{thought.toolSummary && (
|
||||
|
|
@ -355,6 +387,8 @@ export const LeadThoughtsGroupRow = ({
|
|||
canBeLive,
|
||||
zebraShade,
|
||||
collapseState,
|
||||
onTaskIdClick,
|
||||
memberColorMap,
|
||||
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -676,6 +710,8 @@ export const LeadThoughtsGroupRow = ({
|
|||
thought={thought}
|
||||
showDivider={idx > 0}
|
||||
shouldAnimate={isLive && idx === chronologicalThoughts.length - 1}
|
||||
onTaskIdClick={onTaskIdClick}
|
||||
memberColorMap={memberColorMap}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@ import type { AttachmentPayload } from '@shared/types';
|
|||
interface AttachmentPreviewItemProps {
|
||||
attachment: AttachmentPayload;
|
||||
onRemove: (id: string) => void;
|
||||
onPreview?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const AttachmentPreviewItem = ({
|
||||
attachment,
|
||||
onRemove,
|
||||
onPreview,
|
||||
disabled,
|
||||
}: AttachmentPreviewItemProps): React.JSX.Element => {
|
||||
const dataUrl = `data:${attachment.mimeType};base64,${attachment.data}`;
|
||||
|
|
@ -25,7 +27,7 @@ export const AttachmentPreviewItem = ({
|
|||
<Ban size={18} className="text-red-400" />
|
||||
</div>
|
||||
) : 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">
|
||||
<span className="max-w-[100px] truncate text-[11px] text-[var(--color-text-secondary)]">
|
||||
{attachment.filename}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { AlertCircle, X } from 'lucide-react';
|
||||
|
||||
import { AttachmentPreviewItem } from './AttachmentPreviewItem';
|
||||
import { ImageLightbox } from './ImageLightbox';
|
||||
|
||||
import type { AttachmentPayload } from '@shared/types';
|
||||
|
||||
|
|
@ -23,17 +26,25 @@ export const AttachmentPreviewList = ({
|
|||
disabled,
|
||||
disabledHint,
|
||||
}: AttachmentPreviewListProps): React.JSX.Element | null => {
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
||||
if (attachments.length === 0 && !error) return null;
|
||||
|
||||
const lightboxSlides = attachments.map((att) => ({
|
||||
src: `data:${att.mimeType};base64,${att.data}`,
|
||||
alt: att.filename,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5 px-1">
|
||||
{attachments.length > 0 ? (
|
||||
<div className="flex gap-2 overflow-x-auto py-1">
|
||||
{attachments.map((att) => (
|
||||
{attachments.map((att, i) => (
|
||||
<AttachmentPreviewItem
|
||||
key={att.id}
|
||||
attachment={att}
|
||||
onRemove={onRemove}
|
||||
onPreview={() => setLightboxIndex(i)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -63,6 +74,14 @@ export const AttachmentPreviewList = ({
|
|||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{lightboxIndex !== null && lightboxSlides[lightboxIndex] ? (
|
||||
<ImageLightbox
|
||||
open
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
slides={lightboxSlides}
|
||||
index={lightboxIndex}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { CopyButton } from '@renderer/components/common/CopyButton';
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
|
||||
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. */
|
||||
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. */
|
||||
|
|
@ -190,7 +191,11 @@ export const TaskCommentsSection = ({
|
|||
}
|
||||
>
|
||||
<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' ? (
|
||||
<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} />
|
||||
|
|
@ -232,6 +237,9 @@ export const TaskCommentsSection = ({
|
|||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Reply to comment</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<CopyButton text={comment.text} inline />
|
||||
</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const reply = parseMessageReply(comment.text);
|
||||
|
|
|
|||
|
|
@ -76,7 +76,6 @@ export const MemberLogsTab = ({
|
|||
const refreshHideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [error, setError] = 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 [detailLoading, setDetailLoading] = useState(false);
|
||||
const [previewChunks, setPreviewChunks] = useState<EnhancedChunk[] | null>(null);
|
||||
|
|
@ -92,10 +91,6 @@ export const MemberLogsTab = ({
|
|||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
expandedIdRef.current = expandedId;
|
||||
}, [expandedId]);
|
||||
|
||||
const beginRefreshing = useCallback((): void => {
|
||||
if (refreshCountRef.current === 0) {
|
||||
refreshBeganAtRef.current = Date.now();
|
||||
|
|
@ -282,16 +277,6 @@ export const MemberLogsTab = ({
|
|||
setLogs(nextLogs);
|
||||
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) {
|
||||
if (!cancelled) {
|
||||
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(() => {
|
||||
if (!shouldShowPreview) {
|
||||
setPreviewChunks(null);
|
||||
|
|
@ -419,10 +384,7 @@ export const MemberLogsTab = ({
|
|||
useEffect(() => {
|
||||
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
|
||||
if (!expandedLogSummary) return;
|
||||
// When task logs are auto-refreshing, the summary refresh loop also refreshes
|
||||
// expanded details to keep everything in sync (and avoid duplicate requests).
|
||||
if (shouldAutoRefreshSummary) return;
|
||||
if (!expandedLogSummary.isOngoing) return;
|
||||
if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
|
|
@ -440,6 +402,7 @@ export const MemberLogsTab = ({
|
|||
}
|
||||
};
|
||||
|
||||
void refreshDetail();
|
||||
const interval = setInterval(() => void refreshDetail(), 5000);
|
||||
|
||||
return () => {
|
||||
|
|
|
|||
|
|
@ -250,7 +250,7 @@ export const MessageComposer = ({
|
|||
>
|
||||
<DropZoneOverlay active={isDragOver && !!canAttach} />
|
||||
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
{isLeadRecipient ? (
|
||||
<>
|
||||
<input
|
||||
|
|
@ -266,7 +266,7 @@ export const MessageComposer = ({
|
|||
<button
|
||||
type="button"
|
||||
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
|
||||
? 'text-[var(--color-text-secondary)] hover:text-[var(--color-text)]'
|
||||
: 'text-[var(--color-text-muted)] opacity-40'
|
||||
|
|
@ -285,10 +285,29 @@ export const MessageComposer = ({
|
|||
: 'Attach images (paste or drag & drop)'}
|
||||
</TooltipContent>
|
||||
</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 ? (
|
||||
<span className="text-[10px]" style={{ color: 'var(--warning-text)' }}>
|
||||
Team offline
|
||||
|
|
@ -395,15 +414,6 @@ export const MessageComposer = ({
|
|||
</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
|
||||
id={`compose-${teamName}`}
|
||||
placeholder="Write a message... (Enter to send, Shift+Enter for new line)"
|
||||
|
|
|
|||
|
|
@ -774,13 +774,20 @@ body {
|
|||
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 {
|
||||
min-width: 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 {
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(1px 1px 2px rgba(0, 0, 0, 0.8));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue