diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 7fa82f5d..4c2748b4 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -1367,7 +1367,34 @@ export class TeamDataService { async updateKanban(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise { if (patch.op !== 'request_changes') { + // Keep kanban + task.status consistent: + // - moving a task into kanban review/approved implies the work is complete + // - request_changes already moves it back to in_progress and clears kanban entry + if (patch.op !== 'set_column') { + await this.kanbanManager.updateTask(teamName, taskId, patch); + return; + } + + const previousState = await this.kanbanManager.getState(teamName); + const previousKanbanEntry: KanbanTaskState | undefined = previousState.tasks[taskId]; + await this.kanbanManager.updateTask(teamName, taskId, patch); + + try { + await this.taskWriter.updateStatus(teamName, taskId, 'completed', 'user'); + } catch (error) { + // Best-effort rollback of kanban move if task status update failed. + if (previousKanbanEntry) { + await this.kanbanManager + .updateTask(teamName, taskId, { op: 'set_column', column: previousKanbanEntry.column }) + .catch(() => undefined); + } else { + await this.kanbanManager + .updateTask(teamName, taskId, { op: 'remove' }) + .catch(() => undefined); + } + throw error; + } return; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 1d5f2840..2bc6fa9e 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -156,6 +156,11 @@ interface ProvisioningRun { * Flushed to liveLeadProcessMessages on result.success. */ directReplyParts: string[]; + /** + * When set, the current stdin-injected turn is an internal "forward user DM to teammate" + * request triggered by the UI. We suppress any lead→user echo for that turn. + */ + silentUserDmForward: { target: string; startedAt: string } | null; /** Accumulates assistant text during provisioning phase for live UI preview. */ provisioningOutputParts: string[]; /** Session ID detected from stream-json output (result.session_id or message.session_id). */ @@ -1678,6 +1683,7 @@ export class TeamProvisioningService { fsPhase: 'waiting_config', leadRelayCapture: null, directReplyParts: [], + silentUserDmForward: null, provisioningOutputParts: [], detectedSessionId: null, leadActivityState: 'active', @@ -1967,6 +1973,7 @@ export class TeamProvisioningService { fsPhase: 'waiting_members', leadRelayCapture: null, directReplyParts: [], + silentUserDmForward: null, provisioningOutputParts: [], detectedSessionId: null, leadActivityState: 'active', @@ -2208,6 +2215,52 @@ export class TeamProvisioningService { this.setLeadActivity(run, 'active'); } + /** + * Best-effort: forward a user-written DM to a teammate via the live lead process. + * This covers cases where teammates don't automatically respond to inbox JSON, + * and only react to Claude Code internal SendMessage routing. + * + * Note: We suppress the lead's textual output for this injected turn to avoid + * confusing lead responses like "No action needed." + */ + async forwardUserDmToTeammate( + teamName: string, + teammateName: string, + userText: string, + userSummary?: string + ): Promise { + const runId = this.activeByTeam.get(teamName); + if (!runId) { + throw new Error(`No active process for team "${teamName}"`); + } + const run = this.runs.get(runId); + if (!run?.child?.stdin?.writable) { + throw new Error(`Team "${teamName}" process stdin is not writable`); + } + if (!run.provisioningComplete) { + // Don't inject extra turns during provisioning/bootstrap. + return; + } + + run.silentUserDmForward = { target: teammateName, startedAt: nowIso() }; + + const summaryLine = userSummary?.trim() ? `Summary: ${userSummary.trim()}` : null; + const message = [ + `INTERNAL: The human user sent a direct message to teammate "${teammateName}" via the UI.`, + `Action: forward it to that teammate using the SendMessage tool.`, + `IMPORTANT: Do NOT reply to the human user for this turn.`, + `In the forwarded message, ask the teammate to reply to recipient "user" with a short answer.`, + ``, + `User message:`, + ...(summaryLine ? [summaryLine] : []), + userText, + ] + .filter(Boolean) + .join('\n'); + + await this.sendMessageToTeam(teamName, message); + } + /** * Relay unread inbox messages addressed to the team lead into the live lead process. * @@ -2731,7 +2784,9 @@ export class TeamProvisioningService { } } else if (run.provisioningComplete) { // Accumulate assistant text for direct user→lead messages (no relay capture). - run.directReplyParts.push(text); + if (!run.silentUserDmForward) { + run.directReplyParts.push(text); + } } } @@ -2740,7 +2795,7 @@ 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) { + if (run.provisioningComplete && !run.silentUserDmForward) { this.captureSendMessageToUser(run, content ?? []); } diff --git a/src/main/services/team/TeamTaskWriter.ts b/src/main/services/team/TeamTaskWriter.ts index d1d6603e..207c6cff 100644 --- a/src/main/services/team/TeamTaskWriter.ts +++ b/src/main/services/team/TeamTaskWriter.ts @@ -319,6 +319,9 @@ export class TeamTaskWriter { const task = JSON.parse(raw) as TeamTask; const prevStatus = task.status; + if (prevStatus === status) { + return; + } const nowIso = new Date().toISOString(); // Maintain workIntervals as periods of time where status === 'in_progress'. diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx index 5cae2663..39dc5742 100644 --- a/src/renderer/components/team/ClaudeLogsSection.tsx +++ b/src/renderer/components/team/ClaudeLogsSection.tsx @@ -398,6 +398,7 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J // Parser expects chronological order; UI shows newest-first. cliLogsTail={filteredText} order="newest-first" + searchQueryOverride={searchQuery.trim() ? searchQuery : undefined} className="max-h-[320px] p-2" containerRefCallback={(el) => { logContainerRef.current = el;