feat: enhance TeamProvisioningService and UI components for lead text handling

- Added new properties to ProvisioningRun for managing lead text emission state and throttling.
- Implemented logic to push lead text messages immediately after provisioning, improving real-time updates in the UI.
- Updated DisplayItemList to support customizable render order for display items.
- Refactored task assignment visibility in various components to replace "Не назначено" with "Unassigned" for better clarity.
- Enhanced TaskDetailDialog and KanbanTaskCard to improve user experience with task ownership display.
This commit is contained in:
iliya 2026-03-05 20:22:25 +02:00
parent 62cda45a91
commit 6a67838d20
9 changed files with 124 additions and 59 deletions

View file

@ -157,6 +157,10 @@ interface ProvisioningRun {
* Flushed to liveLeadProcessMessages on result.success.
*/
directReplyParts: string[];
/** Whether we already emitted live lead text during the current turn (before result). */
leadTextPushedInCurrentTurn: boolean;
/** Throttle timestamp for emitting inbox refresh events for lead text. */
lastLeadTextEmitMs: number;
/**
* When set, the current stdin-injected turn is an internal "forward user DM to teammate"
* request triggered by the UI. We suppress any leaduser echo for that turn.
@ -1166,6 +1170,8 @@ export class TeamProvisioningService {
}
private static readonly CONTEXT_EMIT_THROTTLE_MS = 2000;
private static readonly LEAD_TEXT_EMIT_THROTTLE_MS = 2000;
private static readonly LEAD_TEXT_MIN_LENGTH = 30;
private emitLeadContextUsage(run: ProvisioningRun): void {
if (!run.leadContextUsage || !run.provisioningComplete) return;
@ -1694,6 +1700,8 @@ export class TeamProvisioningService {
fsPhase: 'waiting_config',
leadRelayCapture: null,
directReplyParts: [],
leadTextPushedInCurrentTurn: false,
lastLeadTextEmitMs: 0,
silentUserDmForward: null,
silentUserDmForwardClearHandle: null,
provisioningOutputParts: [],
@ -1992,6 +2000,8 @@ export class TeamProvisioningService {
fsPhase: 'waiting_members',
leadRelayCapture: null,
directReplyParts: [],
leadTextPushedInCurrentTurn: false,
lastLeadTextEmitMs: 0,
silentUserDmForward: null,
silentUserDmForwardClearHandle: null,
provisioningOutputParts: [],
@ -2798,6 +2808,39 @@ export class TeamProvisioningService {
return;
}
logger.debug(`[${run.teamName}] assistant: ${text.slice(0, 200)}`);
// After provisioning, surface lead assistant output in Messages immediately.
// Lead session JSONL changes are not watched, so without an explicit trigger
// the Messages tab may lag behind Claude Logs until another team-change event.
if (run.provisioningComplete && !run.leadRelayCapture && !run.silentUserDmForward) {
const cleanText = stripAgentBlocks(text).trim();
if (cleanText.length >= TeamProvisioningService.LEAD_TEXT_MIN_LENGTH) {
const leadName =
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
'team-lead';
const leadMsg: InboxMessage = {
from: leadName,
to: 'user',
text: cleanText,
timestamp: nowIso(),
read: true,
summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText,
messageId: `lead-text-${run.runId}-${Date.now()}`,
source: 'lead_process',
};
this.pushLiveLeadProcessMessage(run.teamName, leadMsg);
run.leadTextPushedInCurrentTurn = true;
const now = Date.now();
if (now - run.lastLeadTextEmitMs >= TeamProvisioningService.LEAD_TEXT_EMIT_THROTTLE_MS) {
run.lastLeadTextEmitMs = now;
this.teamChangeEmitter?.({
type: 'inbox',
teamName: run.teamName,
detail: 'lead-text',
});
}
}
}
// During provisioning (before provisioningComplete), accumulate for live UI preview.
// Emission is handled by the throttled emitLogsProgress() in the stdout data handler.
if (!run.provisioningComplete) {
@ -2965,59 +3008,63 @@ export class TeamProvisioningService {
// Flush accumulated assistant reply from direct user→lead message
const rawReply = run.directReplyParts.join('').trim();
run.directReplyParts = [];
const leadName =
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
'team-lead';
// Strip agent-only blocks — lead may include coordination content not meant for the user
const replyText = stripAgentBlocks(rawReply);
if (replyText.length > 0) {
const replyMsg: InboxMessage = {
from: leadName,
to: 'user',
text: replyText,
timestamp: nowIso(),
read: true,
summary: replyText.length > 60 ? replyText.slice(0, 57) + '...' : replyText,
messageId: `lead-direct-${run.runId}-${Date.now()}`,
source: 'lead_process',
};
this.pushLiveLeadProcessMessage(run.teamName, replyMsg);
// Persist to disk so replies survive app restart
void this.sentMessagesStore
.appendMessage(run.teamName, replyMsg)
.catch((e: unknown) =>
logger.warn(`[${run.teamName}] sentMessagesStore persist failed: ${e}`)
);
this.teamChangeEmitter?.({
type: 'inbox',
teamName: run.teamName,
detail: 'lead-direct-reply',
});
} else if (rawReply.length > 0) {
// Lead responded but only with agent-only content — send generic acknowledgment
const fallbackMsg: InboxMessage = {
from: leadName,
to: 'user',
text: '(Message received and processed)',
timestamp: nowIso(),
read: true,
summary: 'Message processed',
messageId: `lead-direct-${run.runId}-${Date.now()}`,
source: 'lead_process',
};
this.pushLiveLeadProcessMessage(run.teamName, fallbackMsg);
void this.sentMessagesStore
.appendMessage(run.teamName, fallbackMsg)
.catch((e: unknown) =>
logger.warn(`[${run.teamName}] sentMessagesStore persist failed: ${e}`)
);
this.teamChangeEmitter?.({
type: 'inbox',
teamName: run.teamName,
detail: 'lead-direct-reply',
});
if (!run.leadTextPushedInCurrentTurn) {
const leadName =
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
'team-lead';
// Strip agent-only blocks — lead may include coordination content not meant for the user
const replyText = stripAgentBlocks(rawReply);
if (replyText.length > 0) {
const replyMsg: InboxMessage = {
from: leadName,
to: 'user',
text: replyText,
timestamp: nowIso(),
read: true,
summary: replyText.length > 60 ? replyText.slice(0, 57) + '...' : replyText,
messageId: `lead-direct-${run.runId}-${Date.now()}`,
source: 'lead_process',
};
this.pushLiveLeadProcessMessage(run.teamName, replyMsg);
// Persist to disk so replies survive app restart
void this.sentMessagesStore
.appendMessage(run.teamName, replyMsg)
.catch((e: unknown) =>
logger.warn(`[${run.teamName}] sentMessagesStore persist failed: ${e}`)
);
this.teamChangeEmitter?.({
type: 'inbox',
teamName: run.teamName,
detail: 'lead-direct-reply',
});
} else if (rawReply.length > 0) {
// Lead responded but only with agent-only content — send generic acknowledgment
const fallbackMsg: InboxMessage = {
from: leadName,
to: 'user',
text: '(Message received and processed)',
timestamp: nowIso(),
read: true,
summary: 'Message processed',
messageId: `lead-direct-${run.runId}-${Date.now()}`,
source: 'lead_process',
};
this.pushLiveLeadProcessMessage(run.teamName, fallbackMsg);
void this.sentMessagesStore
.appendMessage(run.teamName, fallbackMsg)
.catch((e: unknown) =>
logger.warn(`[${run.teamName}] sentMessagesStore persist failed: ${e}`)
);
this.teamChangeEmitter?.({
type: 'inbox',
teamName: run.teamName,
detail: 'lead-direct-reply',
});
}
}
}
// Turn boundary: reset per-turn lead text tracking.
run.leadTextPushedInCurrentTurn = false;
// Clear silent relay flag after any successful turn.
run.silentUserDmForward = null;
if (run.silentUserDmForwardClearHandle) {
@ -3034,6 +3081,8 @@ export class TeamProvisioningService {
if (run.leadRelayCapture) {
run.leadRelayCapture.rejectOnce(errorMsg);
}
// Turn boundary: reset per-turn lead text tracking.
run.leadTextPushedInCurrentTurn = false;
// Clear silent relay flag after any errored turn.
run.silentUserDmForward = null;
if (run.silentUserDmForwardClearHandle) {

View file

@ -29,6 +29,8 @@ interface DisplayItemListProps {
onItemClick: (itemId: string) => void;
expandedItemIds: Set<string>;
aiGroupId: string;
/** Render order for display items (visual only). */
order?: 'chronological' | 'newest-first';
/** Optional local search query override for markdown highlighting */
searchQueryOverride?: string;
/** Tool use ID to highlight for error deep linking */
@ -68,6 +70,7 @@ export const DisplayItemList = ({
onItemClick,
expandedItemIds,
aiGroupId,
order = 'chronological',
searchQueryOverride,
highlightToolUseId,
highlightColor,
@ -99,7 +102,13 @@ export const DisplayItemList = ({
}
return (
<div className="min-w-0 space-y-2">
<div
className={
order === 'newest-first'
? 'min-w-0 flex flex-col-reverse gap-2'
: 'min-w-0 space-y-2'
}
>
{items.map((item, index) => {
let itemKey = '';
let element: React.ReactNode = null;

View file

@ -111,7 +111,7 @@ export const TaskTooltip = ({
color={colorMap.get(task.owner)}
/>
) : (
<span className="text-[10px] text-[var(--color-text-muted)]">Не назначено</span>
<span className="text-[10px] text-[var(--color-text-muted)]">Unassigned</span>
)}
</div>

View file

@ -1381,6 +1381,14 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
title="CLI Processes"
icon={<Terminal size={14} />}
badge={data.processes.filter((p) => !p.stoppedAt).length}
headerExtra={
data.processes.some((p) => !p.stoppedAt) ? (
<span className="pointer-events-none relative inline-flex size-2 shrink-0" title="Active">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
</span>
) : null
}
defaultOpen
>
<ProcessesSection />

View file

@ -357,7 +357,7 @@ export const TaskDetailDialog = ({
size="md"
/>
) : (
<span className="text-xs italic text-[var(--color-text-muted)]">Не назначено</span>
<span className="text-xs italic text-[var(--color-text-muted)]">Unassigned</span>
)}
</div>
{currentTask.createdBy ? (

View file

@ -270,9 +270,7 @@ export const KanbanTaskCard = ({
<div className="flex items-center gap-1">
{task.owner ? (
<MemberBadge name={task.owner} color={colorMap.get(task.owner)} />
) : (
<span className="text-[10px] italic text-[var(--color-text-muted)]">Не назначено</span>
)}
) : null}
{!compact && <TruncatedTitle text={task.subject} className="min-w-0" />}
</div>
{task.needsClarification ? (

View file

@ -66,7 +66,7 @@ export const TrashDialog = ({
<td className="py-2 pr-3 text-[var(--color-text-muted)]">{task.id}</td>
<td className="py-2 pr-3 text-[var(--color-text)]">{task.subject}</td>
<td className="py-2 pr-3 text-[var(--color-text-secondary)]">
{task.owner ?? 'Не назначено'}
{task.owner ?? 'Unassigned'}
</td>
<td className="py-2 pr-3 text-[var(--color-text-muted)]">
{task.deletedAt

View file

@ -214,6 +214,7 @@ const AIExecutionGroup = ({
<div className="py-1 pl-2">
<DisplayItemList
items={enhanced.displayItems}
order="newest-first"
onItemClick={onToggleItem}
expandedItemIds={expandedItemIds}
aiGroupId={group.id}

View file

@ -14,7 +14,7 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
<tr className="border-t border-[var(--color-border)]">
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.id}</td>
<td className="px-3 py-2 text-sm text-[var(--color-text)]">{task.subject}</td>
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.owner ?? 'Не назначено'}</td>
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.owner ?? 'Unassigned'}</td>
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
{task.kanbanColumn && task.kanbanColumn in KANBAN_COLUMN_DISPLAY
? KANBAN_COLUMN_DISPLAY[task.kanbanColumn].label