From 3a8179d98010cc82aaacadfc61fecb9579b195ee Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 5 Mar 2026 17:59:49 +0200 Subject: [PATCH] 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. --- .../services/team/TeamProvisioningService.ts | 130 ++++++++++-------- .../components/chat/DisplayItemList.tsx | 14 +- .../components/chat/items/TextItem.tsx | 14 +- .../components/chat/items/ThinkingItem.tsx | 14 +- .../chat/viewers/MarkdownViewer.tsx | 10 +- .../components/team/CliLogsRichView.tsx | 11 ++ src/renderer/components/team/TaskTooltip.tsx | 2 +- .../team/dialogs/TaskAttachments.tsx | 27 ++-- .../team/dialogs/TaskDetailDialog.tsx | 2 +- .../components/team/kanban/KanbanTaskCard.tsx | 4 +- .../components/team/kanban/TrashDialog.tsx | 2 +- .../components/team/tasks/TaskRow.tsx | 2 +- src/renderer/utils/streamJsonParser.ts | 33 ++++- 13 files changed, 181 insertions(+), 84 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 1c00b132..1d5f2840 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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 { 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); diff --git a/src/renderer/components/chat/DisplayItemList.tsx b/src/renderer/components/chat/DisplayItemList.tsx index 1fd2db8e..ecd5a649 100644 --- a/src/renderer/components/chat/DisplayItemList.tsx +++ b/src/renderer/components/chat/DisplayItemList.tsx @@ -29,6 +29,8 @@ interface DisplayItemListProps { onItemClick: (itemId: string) => void; expandedItemIds: Set; 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)} > - + ); break; diff --git a/src/renderer/components/chat/items/TextItem.tsx b/src/renderer/components/chat/items/TextItem.tsx index 63b9d17c..ea0cba41 100644 --- a/src/renderer/components/chat/items/TextItem.tsx +++ b/src/renderer/components/chat/items/TextItem.tsx @@ -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 = ({ preview, onClick, isExpanded, + searchQueryOverride, + markdownItemId, highlightClasses, highlightStyle, notificationDotColor, @@ -50,7 +56,13 @@ export const TextItem: React.FC = ({ highlightStyle={highlightStyle} notificationDotColor={notificationDotColor} > - + ); }; diff --git a/src/renderer/components/chat/items/ThinkingItem.tsx b/src/renderer/components/chat/items/ThinkingItem.tsx index e1034ef4..34a6f50d 100644 --- a/src/renderer/components/chat/items/ThinkingItem.tsx +++ b/src/renderer/components/chat/items/ThinkingItem.tsx @@ -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 = ({ preview, onClick, isExpanded, + searchQueryOverride, + markdownItemId, highlightClasses, highlightStyle, notificationDotColor, @@ -50,7 +56,13 @@ export const ThinkingItem: React.FC = ({ highlightStyle={highlightStyle} notificationDotColor={notificationDotColor} > - + ); }; diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index bc681d64..fc5d4d41 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -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 = ({ className = '', label, itemId, + searchQueryOverride, copyable = false, bare = false, baseDir, @@ -590,9 +593,12 @@ export const MarkdownViewer: React.FC = ({ } // 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 diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx index bb04ff47..0b8cd8a5 100644 --- a/src/renderer/components/team/CliLogsRichView.tsx +++ b/src/renderer/components/team/CliLogsRichView.tsx @@ -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; 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; 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} /> )} @@ -134,6 +142,7 @@ export const CliLogsRichView = ({ order = 'oldest-first', onScroll, containerRefCallback, + searchQueryOverride, className, }: CliLogsRichViewProps): React.JSX.Element => { const scrollRef = useRef(null); @@ -242,6 +251,7 @@ export const CliLogsRichView = ({ group={group} expandedItemIds={expandedItemIds} onItemClick={handleItemClick} + searchQueryOverride={searchQueryOverride} /> ) : ( handleGroupToggle(group.id)} expandedItemIds={expandedItemIds} onItemClick={handleItemClick} + searchQueryOverride={searchQueryOverride} /> ) )} diff --git a/src/renderer/components/team/TaskTooltip.tsx b/src/renderer/components/team/TaskTooltip.tsx index f260dccc..7fcc31fc 100644 --- a/src/renderer/components/team/TaskTooltip.tsx +++ b/src/renderer/components/team/TaskTooltip.tsx @@ -111,7 +111,7 @@ export const TaskTooltip = ({ color={colorMap.get(task.owner)} /> ) : ( - Unassigned + Не назначено )} diff --git a/src/renderer/components/team/dialogs/TaskAttachments.tsx b/src/renderer/components/team/dialogs/TaskAttachments.tsx index aa823502..3a59306f 100644 --- a/src/renderer/components/team/dialogs/TaskAttachments.tsx +++ b/src/renderer/components/team/dialogs/TaskAttachments.tsx @@ -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(null); const [error, setError] = useState(null); const [lightboxIndex, setLightboxIndex] = useState(null); - const [lightboxSlides, setLightboxSlides] = useState<{ src: string; alt: string }[]>([]); const [thumbCache, setThumbCache] = useState>(new Map()); const fileInputRef = useRef(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,`, - 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} diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index fa63ac59..405cfdb7 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -357,7 +357,7 @@ export const TaskDetailDialog = ({ size="md" /> ) : ( - + Не назначено )} {currentTask.createdBy ? ( diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index e86968a7..29414aca 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -270,7 +270,9 @@ export const KanbanTaskCard = ({
{task.owner ? ( - ) : null} + ) : ( + Не назначено + )} {!compact && }
{task.needsClarification ? ( diff --git a/src/renderer/components/team/kanban/TrashDialog.tsx b/src/renderer/components/team/kanban/TrashDialog.tsx index fc87384a..97cd1d53 100644 --- a/src/renderer/components/team/kanban/TrashDialog.tsx +++ b/src/renderer/components/team/kanban/TrashDialog.tsx @@ -66,7 +66,7 @@ export const TrashDialog = ({ {task.id} {task.subject} - {task.owner ?? 'Unassigned'} + {task.owner ?? 'Не назначено'} {task.deletedAt diff --git a/src/renderer/components/team/tasks/TaskRow.tsx b/src/renderer/components/team/tasks/TaskRow.tsx index 2fbb7b7e..0f6194b5 100644 --- a/src/renderer/components/team/tasks/TaskRow.tsx +++ b/src/renderer/components/team/tasks/TaskRow.tsx @@ -14,7 +14,7 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => { {task.id} {task.subject} - {task.owner ?? '\u2014'} + {task.owner ?? 'Не назначено'} {task.kanbanColumn && task.kanbanColumn in KANBAN_COLUMN_DISPLAY ? KANBAN_COLUMN_DISPLAY[task.kanbanColumn].label diff --git a/src/renderer/utils/streamJsonParser.ts b/src/renderer/utils/streamJsonParser.ts index 19b3bee9..42e1fa48 100644 --- a/src/renderer/utils/streamJsonParser.ts +++ b/src/renderer/utils/streamJsonParser.ts @@ -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(); +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(); - // 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); }