feat: enhance TeamProvisioningService and UI components for improved caching and search functionality

- Introduced caching mechanisms in TeamProvisioningService to optimize probe results and reduce redundant calls.
- Refactored warmup logic to utilize a new getCachedOrProbeResult method for better performance.
- Enhanced DisplayItemList, TextItem, and ThinkingItem components to support optional search query overrides for improved inline highlighting.
- Updated MarkdownViewer to handle search highlighting more effectively, accommodating local search queries.
- Improved TaskAttachments and TaskDetailDialog components with better handling of task assignment visibility and user experience.
This commit is contained in:
iliya 2026-03-05 17:59:49 +02:00
parent 70fdc2537a
commit 3a8179d980
13 changed files with 181 additions and 84 deletions

View file

@ -988,6 +988,8 @@ interface CachedProbeResult {
}
let cachedProbeResult: CachedProbeResult | null = null;
let probeInFlight: Promise<{ claudePath: string; authSource: ProvisioningAuthSource; warning?: string } | null> | null =
null;
export class TeamProvisioningService {
private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000;
@ -1177,21 +1179,9 @@ export class TeamProvisioningService {
async warmup(): Promise<void> {
try {
if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS) {
return;
}
const claudePath = await ClaudeBinaryResolver.resolve();
if (!claudePath) return;
const { env, authSource } = await this.buildProvisioningEnv();
const cwd = process.cwd();
const probe = await this.probeClaudeRuntime(claudePath, cwd, env);
const warning = probe.warning;
if (warning && this.isAuthFailureWarning(warning)) {
// Don't pin auth failures in cache — user may log in after startup.
cachedProbeResult = null;
} else {
cachedProbeResult = { claudePath, authSource, warning, cachedAtMs: Date.now() };
}
if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS) return;
const result = await this.getCachedOrProbeResult(process.cwd());
if (!result) return;
logger.info('CLI warmup completed');
} catch (error) {
logger.warn(`CLI warmup failed: ${error instanceof Error ? error.message : String(error)}`);
@ -1205,30 +1195,20 @@ export class TeamProvisioningService {
await ensureCwdExists(targetCwdForValidation);
}
if (cachedProbeResult) {
const ageMs = Date.now() - cachedProbeResult.cachedAtMs;
if (ageMs >= PROBE_CACHE_TTL_MS) {
cachedProbeResult = null;
} else {
const { warning, authSource } = cachedProbeResult;
const warnings: string[] = [];
if (warning) warnings.push(warning);
const isAuthFailure = warning ? this.isAuthFailureWarning(warning) : false;
const ready = !warning || authSource !== 'none' || !isAuthFailure;
return {
ready,
message: ready ? 'CLI is warmed up and ready to launch' : warning || 'CLI is not ready',
warnings: warnings.length > 0 ? warnings : undefined,
};
}
const cached = this.getFreshCachedProbeResult();
if (cached) {
const { warning, authSource } = cached;
const warnings: string[] = [];
if (warning) warnings.push(warning);
const isAuthFailure = warning ? this.isAuthFailureWarning(warning) : false;
const ready = !warning || authSource !== 'none' || !isAuthFailure;
return {
ready,
message: ready ? 'CLI is warmed up and ready to launch' : warning || 'CLI is not ready',
warnings: warnings.length > 0 ? warnings : undefined,
};
}
const claudePath = await ClaudeBinaryResolver.resolve();
if (!claudePath) {
throw new Error('Claude CLI not found; install it or provide a valid path');
}
const { env: executionEnv, authSource } = await this.buildProvisioningEnv();
const targetCwd = cwd?.trim() || process.cwd();
if (!path.isAbsolute(targetCwd)) {
throw new Error('cwd must be an absolute path');
@ -1237,39 +1217,30 @@ export class TeamProvisioningService {
const warnings: string[] = [];
const probeResult = await this.getCachedOrProbeResult(targetCwd);
if (!probeResult?.claudePath) {
throw new Error('Claude CLI not found; install it or provide a valid path');
}
const { authSource } = probeResult;
if (authSource === 'anthropic_api_key') {
logger.info('Auth: using explicit ANTHROPIC_API_KEY');
} else if (authSource === 'anthropic_auth_token') {
logger.info('Auth: using ANTHROPIC_AUTH_TOKEN mapped to ANTHROPIC_API_KEY');
}
const probe = await this.probeClaudeRuntime(claudePath, targetCwd, executionEnv);
if (probe.warning) {
const isAuthFailure = this.isAuthFailureWarning(probe.warning);
if (probeResult.warning) {
const isAuthFailure = this.isAuthFailureWarning(probeResult.warning);
if (authSource === 'none' && isAuthFailure) {
// No auth source + preflight indicates auth failure — block to avoid a confusing hang later.
return {
ready: false,
message: probe.warning,
message: probeResult.warning,
warnings: warnings.length > 0 ? warnings : undefined,
};
}
// Preflight warnings (including timeouts) should not block provisioning.
warnings.push(probe.warning);
}
// Cache successful/non-auth-failure results so dialogs don't rerun preflight repeatedly.
// Avoid caching auth failures — user may authenticate externally and retry without app restart.
if (!probe.warning || !this.isAuthFailureWarning(probe.warning)) {
cachedProbeResult = {
claudePath,
authSource,
warning: probe.warning,
cachedAtMs: Date.now(),
};
} else {
cachedProbeResult = null;
warnings.push(probeResult.warning);
}
return {
@ -1279,6 +1250,53 @@ export class TeamProvisioningService {
};
}
private getFreshCachedProbeResult(): CachedProbeResult | null {
if (!cachedProbeResult) return null;
const ageMs = Date.now() - cachedProbeResult.cachedAtMs;
if (ageMs >= PROBE_CACHE_TTL_MS) {
cachedProbeResult = null;
return null;
}
return cachedProbeResult;
}
private async getCachedOrProbeResult(
cwd: string
): Promise<{ claudePath: string; authSource: ProvisioningAuthSource; warning?: string } | null> {
const cached = this.getFreshCachedProbeResult();
if (cached) {
return { claudePath: cached.claudePath, authSource: cached.authSource, warning: cached.warning };
}
if (probeInFlight) {
return await probeInFlight;
}
probeInFlight = (async () => {
const claudePath = await ClaudeBinaryResolver.resolve();
if (!claudePath) return null;
const { env, authSource } = await this.buildProvisioningEnv();
const probe = await this.probeClaudeRuntime(claudePath, cwd, env);
const result = { claudePath, authSource, ...(probe.warning ? { warning: probe.warning } : {}) };
if (!probe.warning || !this.isAuthFailureWarning(probe.warning)) {
cachedProbeResult = { ...result, cachedAtMs: Date.now() };
} else {
// Don't pin auth failures in cache — user may log in externally and retry.
cachedProbeResult = null;
}
return result;
})();
try {
return await probeInFlight;
} finally {
probeInFlight = null;
}
}
private isAuthFailureWarning(text: string): boolean {
const lower = text.toLowerCase();
const has401 = /(^|\D)401(\D|$)/.test(lower);

View file

@ -29,6 +29,8 @@ interface DisplayItemListProps {
onItemClick: (itemId: string) => void;
expandedItemIds: Set<string>;
aiGroupId: string;
/** Optional local search query override for markdown highlighting */
searchQueryOverride?: string;
/** Tool use ID to highlight for error deep linking */
highlightToolUseId?: string;
/** Custom highlight color from trigger */
@ -66,6 +68,7 @@ export const DisplayItemList = ({
onItemClick,
expandedItemIds,
aiGroupId,
searchQueryOverride,
highlightToolUseId,
highlightColor,
notificationColorMap,
@ -120,6 +123,8 @@ export const DisplayItemList = ({
preview={truncateText(item.content, 150)}
onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)}
markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
searchQueryOverride={searchQueryOverride}
/>
);
break;
@ -143,6 +148,8 @@ export const DisplayItemList = ({
preview={truncateText(item.content, 150)}
onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)}
markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
searchQueryOverride={searchQueryOverride}
/>
);
break;
@ -235,7 +242,12 @@ export const DisplayItemList = ({
onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)}
>
<MarkdownViewer content={inputContent} copyable />
<MarkdownViewer
content={inputContent}
copyable
itemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
searchQueryOverride={searchQueryOverride}
/>
</BaseItem>
);
break;

View file

@ -15,6 +15,10 @@ interface TextItemProps {
preview: string;
onClick: () => void;
isExpanded: boolean;
/** Optional local search query for inline highlighting */
searchQueryOverride?: string;
/** Optional stable item id for search highlighting */
markdownItemId?: string;
/** Additional classes for highlighting (e.g., error deep linking) */
highlightClasses?: string;
/** Inline styles for highlighting (used by custom hex colors) */
@ -28,6 +32,8 @@ export const TextItem: React.FC<TextItemProps> = ({
preview,
onClick,
isExpanded,
searchQueryOverride,
markdownItemId,
highlightClasses,
highlightStyle,
notificationDotColor,
@ -50,7 +56,13 @@ export const TextItem: React.FC<TextItemProps> = ({
highlightStyle={highlightStyle}
notificationDotColor={notificationDotColor}
>
<MarkdownViewer content={fullContent} maxHeight="max-h-96" copyable />
<MarkdownViewer
content={fullContent}
maxHeight="max-h-96"
copyable
itemId={markdownItemId}
searchQueryOverride={searchQueryOverride}
/>
</BaseItem>
);
};

View file

@ -15,6 +15,10 @@ interface ThinkingItemProps {
preview: string;
onClick: () => void;
isExpanded: boolean;
/** Optional local search query for inline highlighting */
searchQueryOverride?: string;
/** Optional stable item id for search highlighting */
markdownItemId?: string;
/** Additional classes for highlighting (e.g., error deep linking) */
highlightClasses?: string;
/** Inline styles for highlighting (used by custom hex colors) */
@ -28,6 +32,8 @@ export const ThinkingItem: React.FC<ThinkingItemProps> = ({
preview,
onClick,
isExpanded,
searchQueryOverride,
markdownItemId,
highlightClasses,
highlightStyle,
notificationDotColor,
@ -50,7 +56,13 @@ export const ThinkingItem: React.FC<ThinkingItemProps> = ({
highlightStyle={highlightStyle}
notificationDotColor={notificationDotColor}
>
<MarkdownViewer content={fullContent} maxHeight="max-h-96" copyable />
<MarkdownViewer
content={fullContent}
maxHeight="max-h-96"
copyable
itemId={markdownItemId}
searchQueryOverride={searchQueryOverride}
/>
</BaseItem>
);
};

View file

@ -49,6 +49,8 @@ interface MarkdownViewerProps {
label?: string; // Optional label like "Thinking", "Output", etc.
/** When provided, enables search term highlighting within the markdown */
itemId?: string;
/** Optional override for search highlighting (local search, e.g. Claude logs) */
searchQueryOverride?: string;
/** When true, shows a copy button (overlay when no label, inline in header when label exists) */
copyable?: boolean;
/** When true, renders without wrapper background/border (for embedding inside cards) */
@ -448,6 +450,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
className = '',
label,
itemId,
searchQueryOverride,
copyable = false,
bare = false,
baseDir,
@ -590,9 +593,12 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
}
// Create search context (fresh each render so counter starts at 0)
const effectiveQuery = (searchQueryOverride ?? searchQuery).trim();
const effectiveMatches = searchQueryOverride ? [] : searchMatches;
const effectiveIndex = searchQueryOverride ? -1 : currentSearchIndex;
const searchCtx =
searchQuery && itemId
? createSearchContext(searchQuery, itemId, searchMatches, currentSearchIndex)
effectiveQuery && itemId
? createSearchContext(effectiveQuery, itemId, effectiveMatches, effectiveIndex)
: null;
// Create markdown components with optional search highlighting

View file

@ -21,6 +21,8 @@ interface CliLogsRichViewProps {
order?: 'oldest-first' | 'newest-first';
onScroll?: (params: { scrollTop: number; scrollHeight: number; clientHeight: number }) => void;
containerRefCallback?: (el: HTMLDivElement | null) => void;
/** Optional local search query override for inline highlighting */
searchQueryOverride?: string;
className?: string;
}
@ -46,10 +48,12 @@ const FlatGroupItem = ({
group,
expandedItemIds,
onItemClick,
searchQueryOverride,
}: {
group: StreamJsonGroup;
expandedItemIds: Set<string>;
onItemClick: (itemId: string) => void;
searchQueryOverride?: string;
}): React.JSX.Element => {
const groupItemIds = useMemo(
() => scopedItemIds(expandedItemIds, group.id),
@ -66,6 +70,7 @@ const FlatGroupItem = ({
onItemClick={handleItemClick}
expandedItemIds={groupItemIds}
aiGroupId={group.id}
searchQueryOverride={searchQueryOverride}
/>
);
};
@ -79,12 +84,14 @@ const StreamGroup = ({
onToggle,
expandedItemIds,
onItemClick,
searchQueryOverride,
}: {
group: StreamJsonGroup;
isExpanded: boolean;
onToggle: () => void;
expandedItemIds: Set<string>;
onItemClick: (itemId: string) => void;
searchQueryOverride?: string;
}): React.JSX.Element => {
// Scope item IDs to this group to avoid cross-group collisions
const groupItemIds = useMemo(
@ -122,6 +129,7 @@ const StreamGroup = ({
onItemClick={handleItemClick}
expandedItemIds={groupItemIds}
aiGroupId={group.id}
searchQueryOverride={searchQueryOverride}
/>
</div>
)}
@ -134,6 +142,7 @@ export const CliLogsRichView = ({
order = 'oldest-first',
onScroll,
containerRefCallback,
searchQueryOverride,
className,
}: CliLogsRichViewProps): React.JSX.Element => {
const scrollRef = useRef<HTMLDivElement | null>(null);
@ -242,6 +251,7 @@ export const CliLogsRichView = ({
group={group}
expandedItemIds={expandedItemIds}
onItemClick={handleItemClick}
searchQueryOverride={searchQueryOverride}
/>
) : (
<StreamGroup
@ -251,6 +261,7 @@ export const CliLogsRichView = ({
onToggle={() => handleGroupToggle(group.id)}
expandedItemIds={expandedItemIds}
onItemClick={handleItemClick}
searchQueryOverride={searchQueryOverride}
/>
)
)}

View file

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

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store';
@ -32,7 +32,6 @@ export const TaskAttachments = ({
const [deletingId, setDeletingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [lightboxSlides, setLightboxSlides] = useState<{ src: string; alt: string }[]>([]);
const [thumbCache, setThumbCache] = useState<Map<string, string>>(new Map());
const fileInputRef = useRef<HTMLInputElement>(null);
@ -124,6 +123,19 @@ export const TaskAttachments = ({
[getTaskAttachmentData, teamName, taskId]
);
// 1x1 transparent PNG placeholder for slides where thumb is not yet loaded
const PLACEHOLDER_SRC =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABNjN9GQAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAAA0lEQVQI12P4z8BQDwAEgAF/QualIQAAAABJRU5ErkJggg==';
const lightboxSlides = useMemo(
() =>
imageAttachments.map((a) => ({
src: thumbCache.get(a.id) ?? PLACEHOLDER_SRC,
alt: a.filename,
})),
[imageAttachments, thumbCache]
);
const handlePreview = useCallback(
(att: TaskAttachmentMeta) => {
if (!isImageMimeType(att.mimeType)) {
@ -132,18 +144,10 @@ export const TaskAttachments = ({
}
const idx = imageAttachments.findIndex((a) => a.id === att.id);
if (idx >= 0) {
const snapshot = imageAttachments.map((a) => {
const dataUrl = thumbCache.get(a.id);
return {
src: dataUrl ?? `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>`,
alt: a.filename,
};
});
setLightboxSlides(snapshot);
setLightboxIndex(idx);
}
},
[imageAttachments, thumbCache, handleDownload]
[imageAttachments, handleDownload]
);
// Handle paste events for quick image attachment
@ -222,7 +226,6 @@ export const TaskAttachments = ({
open
onClose={() => {
setLightboxIndex(null);
setLightboxSlides([]);
}}
slides={lightboxSlides}
index={lightboxIndex}

View file

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

View file

@ -270,7 +270,9 @@ export const KanbanTaskCard = ({
<div className="flex items-center gap-1">
{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" />}
</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 ?? 'Unassigned'}
{task.owner ?? 'Не назначено'}
</td>
<td className="py-2 pr-3 text-[var(--color-text-muted)]">
{task.deletedAt

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 ?? '\u2014'}</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.kanbanColumn && task.kanbanColumn in KANBAN_COLUMN_DISPLAY
? KANBAN_COLUMN_DISPLAY[task.kanbanColumn].label

View file

@ -160,6 +160,14 @@ function extractAssistantMessageId(parsed: unknown): string | null {
return null;
}
/**
* Module-level timestamp cache keyed by line content.
* Ensures re-parses of the same log lines preserve their original timestamps
* instead of getting new Date() each time.
*/
const lineTimestampCache = new Map<string, Date>();
const MAX_TIMESTAMP_CACHE_SIZE = 5000;
/**
* Parses stream-json CLI output lines into structured groups for rich rendering.
*
@ -176,8 +184,6 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
let currentGroupId: string | null = null;
// Track how many times each messageId has been seen to disambiguate duplicates
const msgIdOccurrences = new Map<string, number>();
// Stable timestamp for the entire parse (deterministic across re-renders)
const parseTimestamp = new Date();
const flushGroup = (): void => {
if (currentItems.length > 0 && currentTimestamp) {
@ -197,8 +203,10 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
const trimmed = lines[lineIndex].trim();
// Skip empty lines and stream markers
if (!trimmed || trimmed.startsWith('[stdout]') || trimmed.startsWith('[stderr]')) {
// Skip empty lines; stream markers break groups
if (!trimmed) continue;
if (trimmed.startsWith('[stdout]') || trimmed.startsWith('[stderr]')) {
flushGroup();
continue;
}
@ -219,7 +227,20 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
continue;
}
if (!currentTimestamp) currentTimestamp = parseTimestamp;
if (!currentTimestamp) {
// Use stable cached timestamp keyed by line content to survive re-parses
let ts = lineTimestampCache.get(trimmed);
if (!ts) {
ts = new Date();
if (lineTimestampCache.size >= MAX_TIMESTAMP_CACHE_SIZE) {
// Evict oldest entry (first inserted)
const firstKey = lineTimestampCache.keys().next().value as string;
lineTimestampCache.delete(firstKey);
}
lineTimestampCache.set(trimmed, ts);
}
currentTimestamp = ts;
}
if (!currentGroupId) {
const msgId = extractAssistantMessageId(parsed);
if (msgId) {
@ -233,7 +254,7 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
}
}
const items = contentBlocksToDisplayItems(blocks, parseTimestamp, lineIndex);
const items = contentBlocksToDisplayItems(blocks, currentTimestamp!, lineIndex);
currentItems.push(...items);
}