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:
parent
70fdc2537a
commit
3a8179d980
13 changed files with 181 additions and 84 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -357,7 +357,7 @@ export const TaskDetailDialog = ({
|
|||
size="md"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--color-text-muted)]">—</span>
|
||||
<span className="text-xs italic text-[var(--color-text-muted)]">Не назначено</span>
|
||||
)}
|
||||
</div>
|
||||
{currentTask.createdBy ? (
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue