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 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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -357,7 +357,7 @@ export const TaskDetailDialog = ({
|
||||||
size="md"
|
size="md"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">—</span>
|
<span className="text-xs italic text-[var(--color-text-muted)]">Не назначено</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{currentTask.createdBy ? (
|
{currentTask.createdBy ? (
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue