feat(studio): add artifact detail workspace#6
Conversation
There was a problem hiding this comment.
Code Review
This pull request refactors the interactive Studio artifacts interface, replacing the accordion-style cards with a dedicated list and a full-width detail pane, and introduces a custom-rendered, collapsible MindMapViewer with SVG links. Feedback on these changes focuses on resolving state leakage in ArtifactViewer by adding a unique key, correcting the source count fallback logic, and separating the detail mode cleanup effect to prevent layout flickering. Additionally, suggestions were made to centralize local translations, adjust the mind map node width calculation to support Chinese characters, add a CSS transition for smoother hover animations, and replace a non-standard font weight with a standard one.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| </span> | ||
| </div> | ||
| {selectedContent.audioUrl && <audio controls src={selectedContent.audioUrl} className="w-full mb-3" />} | ||
| <ArtifactViewer content={selectedContent} /> |
There was a problem hiding this comment.
Without a unique key (such as selectedContent.id), React will reuse the ArtifactViewer component instance when switching between different artifacts of the same type. This causes internal state (such as openIndex in FAQViewer, cardIndex and answers in FlashcardsViewer, and expandedIds in MindMapViewer) to leak from one artifact to another instead of resetting. Adding key={selectedContent.id} ensures that the viewer resets its state correctly on artifact switch.
| <ArtifactViewer content={selectedContent} /> | |
| <ArtifactViewer key={selectedContent.id} content={selectedContent} /> |
| ].filter(Boolean).join('\n'); | ||
| }; | ||
|
|
||
| const contentSourceCount = (content: GeneratedContent) => content.sourceIds === undefined ? sourceIds.length : content.sourceIds.length; |
There was a problem hiding this comment.
If content.sourceIds is undefined, falling back to sourceIds.length (the currently selected sources in the workspace) will cause the displayed source count of older or restored artifacts to dynamically change whenever the user selects or deselects sources in the sidebar. It is safer and more accurate to fall back to 0 (or handle the undefined case statically) to ensure the source count remains consistent with the artifact's actual generation state.
| const contentSourceCount = (content: GeneratedContent) => content.sourceIds === undefined ? sourceIds.length : content.sourceIds.length; | |
| const contentSourceCount = (content: GeneratedContent) => content.sourceIds?.length ?? 0; |
| useEffect(() => { | ||
| onDetailModeChange?.(Boolean(selectedContent)); | ||
| return () => onDetailModeChange?.(false); | ||
| }, [onDetailModeChange, selectedContent]); |
There was a problem hiding this comment.
When selectedContent changes from one active content to another, the cleanup function () => onDetailModeChange?.(false) runs first, followed immediately by onDetailModeChange?.(true). This rapid state transition (true -> false -> true) can cause unnecessary re-renders or layout flickering in the parent component. Separating the unmount cleanup into its own useEffect avoids this issue.
useEffect(() => {
onDetailModeChange?.(Boolean(selectedContent));
}, [onDetailModeChange, selectedContent]);
useEffect(() => {
return () => onDetailModeChange?.(false);
}, [onDetailModeChange]);
| const TYPE_LABELS: Record<string, { zh: string; en: string }> = { | ||
| podcast_script: { zh: '播客', en: 'Podcast' }, | ||
| mind_map: { zh: '思维导图', en: 'Mind Map' }, | ||
| faq: { zh: 'FAQ', en: 'FAQ' }, | ||
| flashcards: { zh: '卡片', en: 'Flashcards' }, | ||
| quiz: { zh: '测验', en: 'Quiz' }, | ||
| report: { zh: '报告', en: 'Report' }, | ||
| study_guide: { zh: '学习指南', en: 'Study Guide' }, | ||
| data_table: { zh: '数据表', en: 'Data Table' }, | ||
| infographic: { zh: '信息图', en: 'Infographic' }, | ||
| video_overview: { zh: '视频概览', en: 'Video Overview' }, | ||
| ppt_outline: { zh: 'PPT', en: 'PPT' }, | ||
| slide_deck: { zh: 'PPT', en: 'Slide Deck' } | ||
| }; |
There was a problem hiding this comment.
The local TYPE_LABELS constant duplicates and slightly modifies translations that are already defined in the centralized translations object in App.tsx (under tools). To maintain a single source of truth for internationalization and avoid duplication, these labels should be centralized in translations in App.tsx and accessed via the t object.
| const mindNodeWidth = (label: string, depth: number) => { | ||
| const base = depth === 0 ? 165 : depth === 1 ? 160 : 130; | ||
| const max = depth === 0 ? 220 : 200; | ||
| return Math.max(base, Math.min(max, label.length * 7 + 46)); | ||
| }; |
There was a problem hiding this comment.
The mindNodeWidth function estimates the node width using label.length * 7. However, Chinese characters (which are common in this project) are significantly wider than English characters (typically around 12-14px vs 7px). This causes Chinese labels to easily overflow or wrap awkwardly within the node. Estimating the width by counting double-byte characters with a larger multiplier (e.g., 12px) would prevent visual truncation.
| const mindNodeWidth = (label: string, depth: number) => { | |
| const base = depth === 0 ? 165 : depth === 1 ? 160 : 130; | |
| const max = depth === 0 ? 220 : 200; | |
| return Math.max(base, Math.min(max, label.length * 7 + 46)); | |
| }; | |
| const mindNodeWidth = (label: string, depth: number) => { | |
| const base = depth === 0 ? 165 : depth === 1 ? 160 : 130; | |
| const max = depth === 0 ? 220 : 200; | |
| const estimatedWidth = [...label].reduce((sum, char) => sum + (char.charCodeAt(0) > 255 ? 12 : 7), 46); | |
| return Math.max(base, Math.min(max, estimatedWidth)); | |
| }; |
| .artifact-list-item { | ||
| width: 100%; | ||
| display: flex; | ||
| min-height: 74px; | ||
| display: grid; | ||
| grid-template-columns: 42px minmax(0, 1fr) 22px; | ||
| align-items: center; | ||
| justify-content: space-between; | ||
| gap: 10px; | ||
| gap: 12px; | ||
| padding: 12px; | ||
| border-radius: 16px; | ||
| background: rgba(255, 255, 255, 0.86); | ||
| border: 1px solid rgba(120, 53, 15, 0.08); | ||
| box-shadow: 0 8px 20px rgba(120, 53, 15, 0.05); | ||
| text-align: left; | ||
| } |
There was a problem hiding this comment.
The .artifact-list-item:hover class applies a transform: translateY(-1px) and a border-color change, but .artifact-list-item does not have a transition property defined. This makes the hover effect abrupt and jerky. Adding a smooth transition (e.g., transition: all 0.2s ease) will improve the user experience.
| .artifact-list-item { | |
| width: 100%; | |
| display: flex; | |
| min-height: 74px; | |
| display: grid; | |
| grid-template-columns: 42px minmax(0, 1fr) 22px; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 10px; | |
| gap: 12px; | |
| padding: 12px; | |
| border-radius: 16px; | |
| background: rgba(255, 255, 255, 0.86); | |
| border: 1px solid rgba(120, 53, 15, 0.08); | |
| box-shadow: 0 8px 20px rgba(120, 53, 15, 0.05); | |
| text-align: left; | |
| } | |
| .artifact-list-item { | |
| width: 100%; | |
| min-height: 74px; | |
| display: grid; | |
| grid-template-columns: 42px minmax(0, 1fr) 22px; | |
| align-items: center; | |
| gap: 12px; | |
| padding: 12px; | |
| border-radius: 16px; | |
| background: rgba(255, 255, 255, 0.86); | |
| border: 1px solid rgba(120, 53, 15, 0.08); | |
| box-shadow: 0 8px 20px rgba(120, 53, 15, 0.05); | |
| text-align: left; | |
| transition: all 0.2s ease; | |
| } |
| .mind-graph-node { | ||
| position: absolute; | ||
| z-index: 2; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: space-between; | ||
| gap: 8px; | ||
| min-height: 32px; | ||
| border-radius: 999px; | ||
| padding: 5px 10px; | ||
| margin: 4px 0; | ||
| background: white; | ||
| border: 1px solid rgba(120, 53, 15, 0.1); | ||
| color: #334155; | ||
| font-size: 0.82rem; | ||
| font-weight: 800; | ||
| min-width: 0; | ||
| border: 1px solid #bfdbfe; | ||
| border-radius: 10px; | ||
| padding: 9px 11px; | ||
| background: #dbeafe; | ||
| color: #111827; | ||
| font-size: 0.78rem; | ||
| font-weight: 850; | ||
| line-height: 1.2; | ||
| text-align: left; | ||
| box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08); | ||
| } |
There was a problem hiding this comment.
The font weight 850 is non-standard and is not imported in the Google Fonts @import at the top of the file (which only imports up to 800 for Outfit and 700 for Inter). This can lead to inconsistent font rendering across different browsers. It is recommended to use a standard weight like 800.
| .mind-graph-node { | |
| position: absolute; | |
| z-index: 2; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 8px; | |
| min-height: 32px; | |
| border-radius: 999px; | |
| padding: 5px 10px; | |
| margin: 4px 0; | |
| background: white; | |
| border: 1px solid rgba(120, 53, 15, 0.1); | |
| color: #334155; | |
| font-size: 0.82rem; | |
| font-weight: 800; | |
| min-width: 0; | |
| border: 1px solid #bfdbfe; | |
| border-radius: 10px; | |
| padding: 9px 11px; | |
| background: #dbeafe; | |
| color: #111827; | |
| font-size: 0.78rem; | |
| font-weight: 850; | |
| line-height: 1.2; | |
| text-align: left; | |
| box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08); | |
| } | |
| .mind-graph-node { | |
| position: absolute; | |
| z-index: 2; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 8px; | |
| min-width: 0; | |
| border: 1px solid #bfdbfe; | |
| border-radius: 10px; | |
| padding: 9px 11px; | |
| background: #dbeafe; | |
| color: #111827; | |
| font-size: 0.78rem; | |
| font-weight: 800; | |
| line-height: 1.2; | |
| text-align: left; | |
| box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08); | |
| } |
Summary
Validation