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 cachedProbeResult: CachedProbeResult | null = null;
let probeInFlight: Promise<{ claudePath: string; authSource: ProvisioningAuthSource; warning?: string } | null> | null =
null;
export class TeamProvisioningService { export class TeamProvisioningService {
private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000; private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000;
@ -1177,21 +1179,9 @@ export class TeamProvisioningService {
async warmup(): Promise<void> { async warmup(): Promise<void> {
try { try {
if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS) { if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS) return;
return; const result = await this.getCachedOrProbeResult(process.cwd());
} if (!result) 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() };
}
logger.info('CLI warmup completed'); logger.info('CLI warmup completed');
} catch (error) { } catch (error) {
logger.warn(`CLI warmup failed: ${error instanceof Error ? error.message : String(error)}`); logger.warn(`CLI warmup failed: ${error instanceof Error ? error.message : String(error)}`);
@ -1205,30 +1195,20 @@ export class TeamProvisioningService {
await ensureCwdExists(targetCwdForValidation); await ensureCwdExists(targetCwdForValidation);
} }
if (cachedProbeResult) { const cached = this.getFreshCachedProbeResult();
const ageMs = Date.now() - cachedProbeResult.cachedAtMs; if (cached) {
if (ageMs >= PROBE_CACHE_TTL_MS) { const { warning, authSource } = cached;
cachedProbeResult = null; const warnings: string[] = [];
} else { if (warning) warnings.push(warning);
const { warning, authSource } = cachedProbeResult; const isAuthFailure = warning ? this.isAuthFailureWarning(warning) : false;
const warnings: string[] = []; const ready = !warning || authSource !== 'none' || !isAuthFailure;
if (warning) warnings.push(warning); return {
const isAuthFailure = warning ? this.isAuthFailureWarning(warning) : false; ready,
const ready = !warning || authSource !== 'none' || !isAuthFailure; message: ready ? 'CLI is warmed up and ready to launch' : warning || 'CLI is not ready',
return { warnings: warnings.length > 0 ? warnings : undefined,
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(); const targetCwd = cwd?.trim() || process.cwd();
if (!path.isAbsolute(targetCwd)) { if (!path.isAbsolute(targetCwd)) {
throw new Error('cwd must be an absolute path'); throw new Error('cwd must be an absolute path');
@ -1237,39 +1217,30 @@ export class TeamProvisioningService {
const warnings: string[] = []; 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') { if (authSource === 'anthropic_api_key') {
logger.info('Auth: using explicit ANTHROPIC_API_KEY'); logger.info('Auth: using explicit ANTHROPIC_API_KEY');
} else if (authSource === 'anthropic_auth_token') { } else if (authSource === 'anthropic_auth_token') {
logger.info('Auth: using ANTHROPIC_AUTH_TOKEN mapped to ANTHROPIC_API_KEY'); logger.info('Auth: using ANTHROPIC_AUTH_TOKEN mapped to ANTHROPIC_API_KEY');
} }
const probe = await this.probeClaudeRuntime(claudePath, targetCwd, executionEnv); if (probeResult.warning) {
const isAuthFailure = this.isAuthFailureWarning(probeResult.warning);
if (probe.warning) {
const isAuthFailure = this.isAuthFailureWarning(probe.warning);
if (authSource === 'none' && isAuthFailure) { if (authSource === 'none' && isAuthFailure) {
// No auth source + preflight indicates auth failure — block to avoid a confusing hang later. // No auth source + preflight indicates auth failure — block to avoid a confusing hang later.
return { return {
ready: false, ready: false,
message: probe.warning, message: probeResult.warning,
warnings: warnings.length > 0 ? warnings : undefined, warnings: warnings.length > 0 ? warnings : undefined,
}; };
} }
// Preflight warnings (including timeouts) should not block provisioning. // Preflight warnings (including timeouts) should not block provisioning.
warnings.push(probe.warning); warnings.push(probeResult.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;
} }
return { 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 { private isAuthFailureWarning(text: string): boolean {
const lower = text.toLowerCase(); const lower = text.toLowerCase();
const has401 = /(^|\D)401(\D|$)/.test(lower); const has401 = /(^|\D)401(\D|$)/.test(lower);

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

View file

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

View file

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

View file

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

View file

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

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)]">Unassigned</span> <span className="text-[10px] text-[var(--color-text-muted)]">Не назначено</span>
)} )}
</div> </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 { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
@ -32,7 +32,6 @@ export const TaskAttachments = ({
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | 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 [thumbCache, setThumbCache] = useState<Map<string, string>>(new Map());
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -124,6 +123,19 @@ export const TaskAttachments = ({
[getTaskAttachmentData, teamName, taskId] [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( const handlePreview = useCallback(
(att: TaskAttachmentMeta) => { (att: TaskAttachmentMeta) => {
if (!isImageMimeType(att.mimeType)) { if (!isImageMimeType(att.mimeType)) {
@ -132,18 +144,10 @@ export const TaskAttachments = ({
} }
const idx = imageAttachments.findIndex((a) => a.id === att.id); const idx = imageAttachments.findIndex((a) => a.id === att.id);
if (idx >= 0) { 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); setLightboxIndex(idx);
} }
}, },
[imageAttachments, thumbCache, handleDownload] [imageAttachments, handleDownload]
); );
// Handle paste events for quick image attachment // Handle paste events for quick image attachment
@ -222,7 +226,6 @@ export const TaskAttachments = ({
open open
onClose={() => { onClose={() => {
setLightboxIndex(null); setLightboxIndex(null);
setLightboxSlides([]);
}} }}
slides={lightboxSlides} slides={lightboxSlides}
index={lightboxIndex} index={lightboxIndex}

View file

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

View file

@ -270,7 +270,9 @@ 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 ?? 'Unassigned'} {task.owner ?? 'Не назначено'}
</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

@ -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 ?? '\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)]"> <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

View file

@ -160,6 +160,14 @@ function extractAssistantMessageId(parsed: unknown): string | null {
return 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. * 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; let currentGroupId: string | null = null;
// Track how many times each messageId has been seen to disambiguate duplicates // Track how many times each messageId has been seen to disambiguate duplicates
const msgIdOccurrences = new Map<string, number>(); const msgIdOccurrences = new Map<string, number>();
// Stable timestamp for the entire parse (deterministic across re-renders)
const parseTimestamp = new Date();
const flushGroup = (): void => { const flushGroup = (): void => {
if (currentItems.length > 0 && currentTimestamp) { if (currentItems.length > 0 && currentTimestamp) {
@ -197,8 +203,10 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
const trimmed = lines[lineIndex].trim(); const trimmed = lines[lineIndex].trim();
// Skip empty lines and stream markers // Skip empty lines; stream markers break groups
if (!trimmed || trimmed.startsWith('[stdout]') || trimmed.startsWith('[stderr]')) { if (!trimmed) continue;
if (trimmed.startsWith('[stdout]') || trimmed.startsWith('[stderr]')) {
flushGroup();
continue; continue;
} }
@ -219,7 +227,20 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
continue; 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) { if (!currentGroupId) {
const msgId = extractAssistantMessageId(parsed); const msgId = extractAssistantMessageId(parsed);
if (msgId) { 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); currentItems.push(...items);
} }