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. * Flushed to liveLeadProcessMessages on result.success.
*/ */
directReplyParts: string[]; 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" * 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. * 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 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 { private emitLeadContextUsage(run: ProvisioningRun): void {
if (!run.leadContextUsage || !run.provisioningComplete) return; if (!run.leadContextUsage || !run.provisioningComplete) return;
@ -1694,6 +1700,8 @@ export class TeamProvisioningService {
fsPhase: 'waiting_config', fsPhase: 'waiting_config',
leadRelayCapture: null, leadRelayCapture: null,
directReplyParts: [], directReplyParts: [],
leadTextPushedInCurrentTurn: false,
lastLeadTextEmitMs: 0,
silentUserDmForward: null, silentUserDmForward: null,
silentUserDmForwardClearHandle: null, silentUserDmForwardClearHandle: null,
provisioningOutputParts: [], provisioningOutputParts: [],
@ -1992,6 +2000,8 @@ export class TeamProvisioningService {
fsPhase: 'waiting_members', fsPhase: 'waiting_members',
leadRelayCapture: null, leadRelayCapture: null,
directReplyParts: [], directReplyParts: [],
leadTextPushedInCurrentTurn: false,
lastLeadTextEmitMs: 0,
silentUserDmForward: null, silentUserDmForward: null,
silentUserDmForwardClearHandle: null, silentUserDmForwardClearHandle: null,
provisioningOutputParts: [], provisioningOutputParts: [],
@ -2798,6 +2808,39 @@ export class TeamProvisioningService {
return; return;
} }
logger.debug(`[${run.teamName}] assistant: ${text.slice(0, 200)}`); 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. // During provisioning (before provisioningComplete), accumulate for live UI preview.
// Emission is handled by the throttled emitLogsProgress() in the stdout data handler. // Emission is handled by the throttled emitLogsProgress() in the stdout data handler.
if (!run.provisioningComplete) { if (!run.provisioningComplete) {
@ -2965,59 +3008,63 @@ export class TeamProvisioningService {
// Flush accumulated assistant reply from direct user→lead message // Flush accumulated assistant reply from direct user→lead message
const rawReply = run.directReplyParts.join('').trim(); const rawReply = run.directReplyParts.join('').trim();
run.directReplyParts = []; run.directReplyParts = [];
const leadName = if (!run.leadTextPushedInCurrentTurn) {
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || const leadName =
'team-lead'; run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
// Strip agent-only blocks — lead may include coordination content not meant for the user 'team-lead';
const replyText = stripAgentBlocks(rawReply); // Strip agent-only blocks — lead may include coordination content not meant for the user
if (replyText.length > 0) { const replyText = stripAgentBlocks(rawReply);
const replyMsg: InboxMessage = { if (replyText.length > 0) {
from: leadName, const replyMsg: InboxMessage = {
to: 'user', from: leadName,
text: replyText, to: 'user',
timestamp: nowIso(), text: replyText,
read: true, timestamp: nowIso(),
summary: replyText.length > 60 ? replyText.slice(0, 57) + '...' : replyText, read: true,
messageId: `lead-direct-${run.runId}-${Date.now()}`, summary: replyText.length > 60 ? replyText.slice(0, 57) + '...' : replyText,
source: 'lead_process', messageId: `lead-direct-${run.runId}-${Date.now()}`,
}; source: 'lead_process',
this.pushLiveLeadProcessMessage(run.teamName, replyMsg); };
// Persist to disk so replies survive app restart this.pushLiveLeadProcessMessage(run.teamName, replyMsg);
void this.sentMessagesStore // Persist to disk so replies survive app restart
.appendMessage(run.teamName, replyMsg) void this.sentMessagesStore
.catch((e: unknown) => .appendMessage(run.teamName, replyMsg)
logger.warn(`[${run.teamName}] sentMessagesStore persist failed: ${e}`) .catch((e: unknown) =>
); logger.warn(`[${run.teamName}] sentMessagesStore persist failed: ${e}`)
this.teamChangeEmitter?.({ );
type: 'inbox', this.teamChangeEmitter?.({
teamName: run.teamName, type: 'inbox',
detail: 'lead-direct-reply', teamName: run.teamName,
}); detail: 'lead-direct-reply',
} else if (rawReply.length > 0) { });
// Lead responded but only with agent-only content — send generic acknowledgment } else if (rawReply.length > 0) {
const fallbackMsg: InboxMessage = { // Lead responded but only with agent-only content — send generic acknowledgment
from: leadName, const fallbackMsg: InboxMessage = {
to: 'user', from: leadName,
text: '(Message received and processed)', to: 'user',
timestamp: nowIso(), text: '(Message received and processed)',
read: true, timestamp: nowIso(),
summary: 'Message processed', read: true,
messageId: `lead-direct-${run.runId}-${Date.now()}`, summary: 'Message processed',
source: 'lead_process', messageId: `lead-direct-${run.runId}-${Date.now()}`,
}; source: 'lead_process',
this.pushLiveLeadProcessMessage(run.teamName, fallbackMsg); };
void this.sentMessagesStore this.pushLiveLeadProcessMessage(run.teamName, fallbackMsg);
.appendMessage(run.teamName, fallbackMsg) void this.sentMessagesStore
.catch((e: unknown) => .appendMessage(run.teamName, fallbackMsg)
logger.warn(`[${run.teamName}] sentMessagesStore persist failed: ${e}`) .catch((e: unknown) =>
); logger.warn(`[${run.teamName}] sentMessagesStore persist failed: ${e}`)
this.teamChangeEmitter?.({ );
type: 'inbox', this.teamChangeEmitter?.({
teamName: run.teamName, type: 'inbox',
detail: 'lead-direct-reply', 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. // Clear silent relay flag after any successful turn.
run.silentUserDmForward = null; run.silentUserDmForward = null;
if (run.silentUserDmForwardClearHandle) { if (run.silentUserDmForwardClearHandle) {
@ -3034,6 +3081,8 @@ export class TeamProvisioningService {
if (run.leadRelayCapture) { if (run.leadRelayCapture) {
run.leadRelayCapture.rejectOnce(errorMsg); run.leadRelayCapture.rejectOnce(errorMsg);
} }
// Turn boundary: reset per-turn lead text tracking.
run.leadTextPushedInCurrentTurn = false;
// Clear silent relay flag after any errored turn. // Clear silent relay flag after any errored turn.
run.silentUserDmForward = null; run.silentUserDmForward = null;
if (run.silentUserDmForwardClearHandle) { if (run.silentUserDmForwardClearHandle) {

View file

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

View file

@ -111,7 +111,7 @@ export const TaskTooltip = ({
color={colorMap.get(task.owner)} 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> </div>

View file

@ -1381,6 +1381,14 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
title="CLI Processes" title="CLI Processes"
icon={<Terminal size={14} />} icon={<Terminal size={14} />}
badge={data.processes.filter((p) => !p.stoppedAt).length} 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 defaultOpen
> >
<ProcessesSection /> <ProcessesSection />

View file

@ -357,7 +357,7 @@ export const TaskDetailDialog = ({
size="md" 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> </div>
{currentTask.createdBy ? ( {currentTask.createdBy ? (

View file

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

View file

@ -214,6 +214,7 @@ const AIExecutionGroup = ({
<div className="py-1 pl-2"> <div className="py-1 pl-2">
<DisplayItemList <DisplayItemList
items={enhanced.displayItems} items={enhanced.displayItems}
order="newest-first"
onItemClick={onToggleItem} onItemClick={onToggleItem}
expandedItemIds={expandedItemIds} expandedItemIds={expandedItemIds}
aiGroupId={group.id} 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)]"> <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-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-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)]"> <td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
{task.kanbanColumn && task.kanbanColumn in KANBAN_COLUMN_DISPLAY {task.kanbanColumn && task.kanbanColumn in KANBAN_COLUMN_DISPLAY
? KANBAN_COLUMN_DISPLAY[task.kanbanColumn].label ? KANBAN_COLUMN_DISPLAY[task.kanbanColumn].label