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.
|
||||
*/
|
||||
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 lead→user 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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue