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:
parent
62cda45a91
commit
6a67838d20
9 changed files with 124 additions and 59 deletions
|
|
@ -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 lead→user echo for that turn.
|
* request triggered by the UI. We suppress any lead→user 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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue