diff --git a/src/crates/core/src/app_platform/builtin.rs b/src/crates/core/src/app_platform/builtin.rs index 9c704b72..4fbc32c3 100644 --- a/src/crates/core/src/app_platform/builtin.rs +++ b/src/crates/core/src/app_platform/builtin.rs @@ -1230,6 +1230,7 @@ async fn ensure_installed_builtin_marketplace_metadata_current_unlocked( async fn sync_installed_builtin_marketplace_metadata(path_manager: &PathManager) -> CoreResult<()> { let installed_root = path_manager.system_product_apps_dir(); let source_root = product_app_sources_dir(path_manager); + let shared_components = read_installed_shared_components(path_manager).await?; for app_dir in collect_installed_product_app_dirs(&installed_root)? { let app = match read_product_app_definition_for_catalog_issue(&app_dir).await { Ok(app) => app, @@ -1278,11 +1279,17 @@ async fn sync_installed_builtin_marketplace_metadata(path_manager: &PathManager) continue; } - match sync_installed_app_metadata_from_source(&app_dir, &source_dir, &app.id, &app.version) - .await + match sync_installed_app_metadata_from_source( + &app_dir, + &source_dir, + &app.id, + &app.version, + &shared_components, + ) + .await { Ok(true) => log::info!( - "refreshed installed built-in Product App metadata from source: app_id={}, app_version={}, package_dir={}, source_dir={}", + "refreshed installed built-in Product App package from source: app_id={}, app_version={}, package_dir={}, source_dir={}", app.id, app.version, app_dir.display(), @@ -1290,7 +1297,7 @@ async fn sync_installed_builtin_marketplace_metadata(path_manager: &PathManager) ), Ok(false) => {} Err(error) => log::warn!( - "failed to refresh installed built-in Product App metadata from source: app_id={}, app_version={}, package_dir={}, source_dir={}, error={}", + "failed to refresh installed built-in Product App package from source: app_id={}, app_version={}, package_dir={}, source_dir={}, error={}", app.id, app.version, app_dir.display(), @@ -1307,6 +1314,7 @@ async fn sync_installed_app_metadata_from_source( source_dir: &Path, app_id: &str, app_version: &str, + shared_components: &[ComponentDefinition], ) -> CoreResult { let installed_app_path = app_dir.join(APP_JSON); let source_app_path = source_dir.join(APP_JSON); @@ -1357,10 +1365,6 @@ async fn sync_installed_app_metadata_from_source( installed_object.remove("goal"); let package_files_changed = sync_installed_app_package_files_from_source(app_dir, source_dir)?; - if installed == before && !package_files_changed { - return Ok(false); - } - if installed != before { let bytes = serde_json::to_vec_pretty(&installed).map_err(CoreError::from)?; tokio::fs::write(&installed_app_path, bytes) @@ -1373,6 +1377,35 @@ async fn sync_installed_app_metadata_from_source( )) })?; } + let lock_changed = refresh_installed_app_lock_if_needed(app_dir, shared_components).await?; + Ok(installed != before || package_files_changed || lock_changed) +} + +async fn refresh_installed_app_lock_if_needed( + app_dir: &Path, + shared_components: &[ComponentDefinition], +) -> CoreResult { + let package = ProductAppResolver::read_product_app_package(app_dir).await?; + let resolved = + ProductAppResolver::resolve_package_install(package, shared_components.to_vec())?; + let resolved_lock_digest = resolved.lock.digest(); + let current_lock_digest = ProductAppResolver::read_lock(app_dir) + .await + .ok() + .map(|lock| lock.digest()); + let current_component_lock_id = read_product_app_definition_for_catalog_issue(app_dir) + .await + .map(|app| app.component_lock_id) + .ok(); + + if current_lock_digest.as_deref() == Some(resolved_lock_digest.as_str()) + && current_component_lock_id.as_deref() == Some(resolved_lock_digest.as_str()) + { + return Ok(false); + } + + write_app_component_lock_id(app_dir, &resolved.app.component_lock_id).await?; + ProductAppResolver::write_lock(app_dir, &resolved.lock).await?; Ok(true) } @@ -2796,6 +2829,67 @@ mod tests { assert_eq!(lock_after, lock_before); } + #[tokio::test] + async fn installed_builtin_marketplace_sync_refreshes_remotion_private_component_lock() { + let path_manager = path_manager("builtin-marketplace-remotion-lock-sync"); + let app_id = "builtin-remotion-live"; + let version = "19.0.0"; + install_product_app(&path_manager, app_id, version) + .await + .expect("install builtin Remotion Product App"); + let app_dir = path_manager.system_product_app_version_dir(app_id, version); + + let mut stale_lock = ProductAppResolver::read_lock(&app_dir) + .await + .expect("read installed Remotion lock"); + for entry in &mut stale_lock.resolved_components { + if entry.component_id == "remotion-video-agent" + || entry.component_id == "builtin-remotion-runtime" + { + entry.fqid = format!( + "component://{}/{}@1.0.0", + entry.kind.path_segment(), + entry.component_id + ); + entry.source = ComponentSource::Shared; + entry.version = Some("1.0.0".to_string()); + entry.scope = Some("system".to_string()); + entry.implementation_digest = None; + } + } + ProductAppResolver::write_lock(&app_dir, &stale_lock) + .await + .expect("write stale Remotion lock"); + write_app_component_lock_id(&app_dir, &stale_lock.digest()) + .await + .expect("write stale Remotion component lock id"); + + ensure_installed_builtin_marketplace_metadata_current_unlocked(&path_manager) + .await + .expect("sync installed builtin marketplace package"); + + let refreshed_lock = ProductAppResolver::read_lock(&app_dir) + .await + .expect("read refreshed Remotion lock"); + assert!(refreshed_lock.resolved_components.iter().any(|entry| { + entry.component_id == "remotion-video-agent" + && entry.source == ComponentSource::Private + && entry.version.is_none() + && entry.fqid == "app://builtin-remotion-live@19.0.0/agents/remotion-video-agent" + })); + assert!(refreshed_lock.resolved_components.iter().any(|entry| { + entry.component_id == "builtin-remotion-runtime" + && entry.source == ComponentSource::Private + && entry.version.is_none() + && entry.fqid + == "app://builtin-remotion-live@19.0.0/bridges/builtin-remotion-runtime" + })); + let refreshed_app = read_product_app_definition_for_catalog_issue(&app_dir) + .await + .expect("read refreshed Remotion app"); + assert_eq!(refreshed_app.component_lock_id, refreshed_lock.digest()); + } + #[tokio::test] async fn installed_catalog_skips_invalid_private_component_package_and_reports_issue() { let path_manager = path_manager("skip-invalid-private-component"); diff --git a/src/web-ui/src/flow_chat/tool-cards/DesignTokensProposalCard.scss b/src/web-ui/src/flow_chat/tool-cards/DesignTokensProposalCard.scss index c74236f5..b5d2a1af 100644 --- a/src/web-ui/src/flow_chat/tool-cards/DesignTokensProposalCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/DesignTokensProposalCard.scss @@ -581,6 +581,23 @@ } } +.design-tokens-proposal-card__compact-swatches { + display: inline-flex; + align-items: center; + gap: 3px; + margin-inline-start: 7px; + vertical-align: -1px; + + span { + width: 10px; + height: 10px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--ds-chat-text-primary) 12%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ds-chat-surface) 26%, transparent); + flex: 0 0 auto; + } +} + /* Align proposal-card action controls with compact tool-card density. */ .design-tokens-proposal-card { --size-radius-sm: 10px; diff --git a/src/web-ui/src/flow_chat/tool-cards/DesignTokensProposalCard.test.tsx b/src/web-ui/src/flow_chat/tool-cards/DesignTokensProposalCard.test.tsx new file mode 100644 index 00000000..ca84f65a --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/DesignTokensProposalCard.test.tsx @@ -0,0 +1,248 @@ +/** + * @vitest-environment jsdom + */ + +import React from 'react'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DesignTokensProposalCard } from './DesignTokensProposalCard'; +import type { FlowToolItem, ToolCardConfig } from '../types/flow-chat'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@/design-system', () => ({ + Button: ({ children, className, disabled, title, type = 'button' }: any) => ( + + ), + DotMatrixLoader: () => , + IconButton: ({ children, className, onClick, type = 'button', ...rest }: any) => ( + + ), + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ + useLastUsedWorkspace: () => ({ workspacePath: 'D:/workspace/project' }), +})); + +vi.mock('@/tools/design-canvas', () => ({ + designTokensAPI: { + awaitSelection: vi.fn(), + commit: vi.fn(), + }, +})); + +vi.mock('@/shared/services/ide-control', () => ({ + ideControl: { + panel: { + open: vi.fn(), + }, + }, +})); + +vi.mock('@/infrastructure/api/service-api/ToolAPI', () => ({ + toolAPI: { + submitUserAnswers: vi.fn(), + }, +})); + +vi.mock('@/shared/utils/logger', () => ({ + createLogger: () => ({ + error: vi.fn(), + warn: vi.fn(), + }), +})); + +vi.mock('./BaseToolCard', () => ({ + BaseToolCard: ({ className, header, expandedContent, isExpanded, onClick }: any) => ( +
+
{header}
+ {isExpanded &&
{expandedContent}
} +
+ ), +})); + +vi.mock('./ToolArtifactFrame', () => ({ + ToolArtifactFrame: ({ children, error, loading }: any) => ( +
+ {error || children} +
+ ), +})); + +vi.mock('./ToolErrorBlock', () => ({ + ToolErrorBlock: ({ title, message }: any) => ( +
+ {title} + {message} +
+ ), +})); + +vi.mock('./ToolHeaderLayout', () => ({ + ToolHeaderLayout: ({ content, extra, icon }: any) => ( +
+ {icon} + {content} + {extra} +
+ ), + ToolCompactHeaderLayout: ({ action, content, extra, statusIcon }: any) => ( +
+ {statusIcon} + {action} + {content} + {extra} +
+ ), +})); + +const config: ToolCardConfig = { + toolName: 'DesignTokens', + displayName: 'Design Tokens', + icon: 'DT', + requiresConfirmation: false, + resultDisplayType: 'detailed', +}; + +const proposal = { + id: 'editorial-ink', + name: 'Editorial Ink', + mood: 'Restrained editorial system', + colors: { + primary: 'var(--ds-color-accent-500)', + background: 'var(--ds-color-bg-scene)', + surface: 'var(--ds-color-surface-base)', + text: 'var(--ds-color-text-primary)', + }, + typography: { + fontFamily: 'Inter, sans-serif', + scale: { + display: '36px', + body: '15px', + }, + }, + radius: { md: '8px' }, + shadow: { md: 'var(--ds-shadow-sm)' }, + motion: {}, + component_samples: {}, +}; + +const alternateProposal = { + ...proposal, + id: 'quiet-system', + name: 'Quiet System', +}; + +function designTokensTool(action: string): FlowToolItem { + return { + id: `tool-${action}`, + type: 'tool', + toolName: 'DesignTokens', + toolCall: { + id: `tool-${action}`, + input: { + action, + artifact_id: 'daily-letter-scene', + proposals: action === 'propose' ? [proposal, alternateProposal] : undefined, + }, + }, + toolResult: { + success: true, + result: { + success: true, + tokens_event: action === 'get' ? 'tokens-preview' : 'tokens-committed', + data: { + path: 'D:/workspace/project/.design/daily-letter-scene/tokens.json', + tokens: { + version: 1, + proposals: [proposal, alternateProposal], + committed_id: 'editorial-ink', + }, + }, + }, + }, + timestamp: 1, + status: 'completed', + }; +} + +describe('DesignTokensProposalCard', () => { + let host: HTMLDivElement | null = null; + let root: Root | null = null; + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + host?.remove(); + root = null; + host = null; + }); + + it('renders get results as a compact committed-token summary', () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('toolCards.designTokens.currentSystemSummary'); + expect(html).toContain('Editorial Ink'); + expect(html).toContain('read-file-name'); + expect(html).toContain('design-tokens-proposal-card__compact-swatches'); + expect(html).not.toContain('read-file-meta'); + expect(html).not.toContain('design-tokens-proposal-card__list'); + expect(html).not.toContain('toolCards.designTokens.switchToThisSystem'); + }); + + it('keeps propose results in the proposal picker layout', () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('toolCards.designTokens.cardTitle'); + expect(html).toContain('design-tokens-proposal-card__list'); + expect(html).toContain('Quiet System'); + expect(html).toContain('design-tokens-proposal-card__collapsed-name'); + }); + + it('lets proposal cards collapse from the header', () => { + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + + act(() => { + root?.render( + , + ); + }); + + expect(host.querySelector('.design-tokens-proposal-card')?.getAttribute('data-expanded')).toBe('true'); + expect(host.querySelector('[data-testid="tool-body"]')).not.toBeNull(); + + act(() => { + (host?.querySelector('.design-tokens-proposal-card') as HTMLElement).click(); + }); + + expect(host.querySelector('.design-tokens-proposal-card')?.getAttribute('data-expanded')).toBe('false'); + expect(host.querySelector('[data-testid="tool-body"]')).toBeNull(); + }); +}); diff --git a/src/web-ui/src/flow_chat/tool-cards/DesignTokensProposalCard.tsx b/src/web-ui/src/flow_chat/tool-cards/DesignTokensProposalCard.tsx index 468bd415..8559e6ad 100644 --- a/src/web-ui/src/flow_chat/tool-cards/DesignTokensProposalCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/DesignTokensProposalCard.tsx @@ -16,6 +16,8 @@ import { ToolHeaderLayout } from './ToolHeaderLayout'; import { deriveToolRuntimeState } from '../runtime/statusModel'; import { getToolViewState } from '../runtime/toolViewState'; import { getToolCardStatusFromViewState } from './toolStatus'; +import { DefaultToolCardTemplate } from './templates'; +import { useToolDisclosureController } from './ToolDisclosureController'; import './DesignTokensProposalCard.scss'; const log = createLogger('DesignTokensProposalCard'); @@ -45,6 +47,7 @@ interface PreviewProps { } const STREAMING_SWATCH_LIMIT = 5; +const SELECTION_ACTIONS = new Set(['propose', 'await_selection']); /** * Estimate the relative luminance of a CSS color string (hex / rgb only). @@ -239,23 +242,38 @@ export const DesignTokensProposalCard: React.FC = ({ toolItem }) const resultPayload = result?.data; const resultTokens = resultPayload?.tokens; const resultPath = resultPayload?.path; + const resultItems: Array<{ path?: string; tokens?: any }> = Array.isArray(resultPayload?.items) + ? resultPayload.items + : []; const selectionStatus: string | undefined = resultPayload?.selection_status; const inputParams = (runtimeState.partialInput || runtimeState.input || toolCall?.input) as Record | undefined; const inputProposals: any[] = Array.isArray(inputParams?.proposals) ? inputParams!.proposals : []; + const action = typeof inputParams?.action === 'string' ? inputParams.action : ''; + const isSelectionAction = SELECTION_ACTIONS.has(action); + const shouldRenderProposalPicker = isSelectionAction || (!action && inputProposals.length > 0); + const isListAction = action === 'list'; const isCompleted = viewState.phase === 'result'; const isFailed = viewState.phase === 'error' || toolResult?.success === false; const failure = toolResult?.error || result?.error || t('toolCards.designTokens.generationFailed'); + const toolCardStatus = getToolCardStatusFromViewState(viewState); // Prefer the authoritative result when the tool has completed; otherwise fall back to the streaming input. const proposals: any[] = resultTokens?.proposals?.length ? resultTokens.proposals : inputProposals; const committedId: string | undefined = resultTokens?.committed_id || undefined; + const committedProposal = committedId + ? proposals.find((proposal) => proposal?.id === committedId) + : undefined; + const compactColorEntries = useMemo( + () => Object.entries(committedProposal?.colors || {}).slice(0, 5) as Array<[string, string]>, + [committedProposal] + ); /** Same as AskUserQuestionCard: no selection until streaming tool args finish (avoids partial proposals). */ const paramsReady = runtimeState.inputPhase !== 'streaming'; const awaitingSelection = - !isCompleted && !isFailed && proposals.length > 0 && paramsReady; + shouldRenderProposalPicker && !isCompleted && !isFailed && proposals.length > 0 && paramsReady; const awaitingPayload = !isCompleted && !isFailed && proposals.length === 0; // User-side local state: which proposal is currently highlighted, and submission progress. @@ -268,10 +286,18 @@ export const DesignTokensProposalCard: React.FC = ({ toolItem }) const [remainingMs, setRemainingMs] = useState(null); const artifactIdFromInput = toolItem.toolCall?.input?.artifact_id as string | undefined; + const toolId = toolItem.id ?? toolItem.toolCall?.id; const scopeKey = useMemo( () => canonicalScopeKey({ explicitPath: resultPath, workspacePath, artifactId: artifactIdFromInput }), [resultPath, workspacePath, artifactIdFromInput] ); + const { cardRootRef, isExpanded, toggleExpanded } = useToolDisclosureController({ + toolId, + toolName: toolItem.toolName, + status: toolCardStatus, + initialExpanded: true, + autoExpandStatuses: ['preparing', 'receiving', 'running', 'pending_confirmation'], + }); // Timeout countdown: start when the card enters the "awaiting selection" // state, clear as soon as the tool completes or errors. @@ -304,9 +330,44 @@ export const DesignTokensProposalCard: React.FC = ({ toolItem }) }); }, [scopeKey, artifactIdFromInput, workspacePath, t]); + const compactSummary = useMemo(() => { + if (isListAction) { + return ( + <> + {t('toolCards.designTokens.documentsSummary')}{' '} + + {t('toolCards.designTokens.documentCountValue', { count: resultItems.length })} + + + ); + } + + if (!committedProposal) { + return ( + <> + {t('toolCards.designTokens.currentSystemSummary')}{' '} + {t('toolCards.designTokens.noCommittedSystem')} + + ); + } + + return ( + <> + {t('toolCards.designTokens.currentSystemSummary')}{' '} + {committedProposal.name || committedProposal.id} + {compactColorEntries.length > 0 && ( +