- {/* The header above the diagram */}
-
-
-
-
-
-
-
setViewMode('diagram')}>
-
- {t('diagram.show_diagram')}
-
-
setViewMode('code')}>
-
- {t('diagram.show_code')}
-
-
-
-
- {/* The drawing part of the diagram */}
-
-
- setZoom(true)} />
-
-
-
-
-
-
- {/* When zooming */}
- {zoom && (
- <>
-
setZoom(false)}
- />
-
e.stopPropagation()}>
-
- setZoom(false)}>
-
-
-
-
-
-
-
- >
- )}
-
- );
-};
-
-export default DiagramRenderer;
diff --git a/packages/web/src/components/Markdown.tsx b/packages/web/src/components/Markdown.tsx
index e99042205..b4bd63268 100644
--- a/packages/web/src/components/Markdown.tsx
+++ b/packages/web/src/components/Markdown.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState, memo } from 'react';
+import React, { useEffect, useMemo, useState, memo } from 'react';
import { BaseProps } from '../@types/common';
import { default as ReactMarkdown } from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -35,6 +35,8 @@ import xmlDoc from 'react-syntax-highlighter/dist/esm/languages/prism/xml-doc';
import yaml from 'react-syntax-highlighter/dist/esm/languages/prism/yaml';
import { useLocation } from 'react-router-dom';
+import { MermaidWithToggle } from './Mermaid/MermaidWithToggle';
+
SyntaxHighlighter.registerLanguage('bash', bash);
SyntaxHighlighter.registerLanguage('c', c);
SyntaxHighlighter.registerLanguage('cpp', cpp);
@@ -58,6 +60,9 @@ SyntaxHighlighter.registerLanguage('tsx', tsx);
SyntaxHighlighter.registerLanguage('xml-doc', xmlDoc);
SyntaxHighlighter.registerLanguage('yaml', yaml);
+// Re-export MermaidWithToggle for backward compatibility
+export { MermaidWithToggle };
+
type Props = BaseProps & {
children: string;
prefix?: string;
@@ -125,12 +130,39 @@ const ImageRenderer = (props: any) => {
return
;
};
+// PreRenderer to skip
tag for mermaid code blocks
+// This prevents the dark prose background from appearing around mermaid diagrams
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const PreRenderer = (props: any) => {
+ const { children } = props;
+
+ // Check if children is a code element with 'language-mermaid' class
+ if (React.isValidElement(children)) {
+ const childProps = children.props as { className?: string };
+ const className = childProps?.className || '';
+ if (className.includes('language-mermaid')) {
+ // Skip tag for mermaid - return children directly
+ return <>{children}>;
+ }
+ }
+
+ // For other code blocks, render normal tag
+ return {children} ;
+};
+
const CodeRenderer = memo(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(props: any) => {
const language = /language-(\w+)/.exec(props.className || '')?.[1];
const codeText = String(props.children).replace(/\n$/, '');
const isCodeBlock = codeText.includes('\n');
+
+ // Render Mermaid diagrams with toggle
+ // Use not-prose to prevent prose styles from affecting the diagram container
+ if (language === 'mermaid') {
+ return ;
+ }
+
return (
<>
{language ? (
@@ -189,6 +221,7 @@ const Markdown = memo(({ className, prefix, children }: Props) => {
sup: ({ children }) => (
{children}
),
+ pre: PreRenderer,
code: CodeRenderer,
}}
/>
diff --git a/packages/web/src/components/MeetingMinutes/MeetingMinutesControlButtons.tsx b/packages/web/src/components/MeetingMinutes/MeetingMinutesControlButtons.tsx
new file mode 100644
index 000000000..d90d113d7
--- /dev/null
+++ b/packages/web/src/components/MeetingMinutes/MeetingMinutesControlButtons.tsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import Button from '../Button';
+import ButtonCopy from '../ButtonCopy';
+import ButtonSendToUseCase from '../ButtonSendToUseCase';
+import { PiStopCircleBold, PiMicrophoneBold } from 'react-icons/pi';
+
+interface MeetingMinutesControlButtonsProps {
+ /** Whether recording is currently active */
+ isRecording: boolean;
+ /** Whether transcript text exists */
+ hasTranscriptText: boolean;
+ /** The transcript text for copy/send operations */
+ transcriptText: string;
+ /** Callback when start recording button is clicked */
+ onStartRecording: () => void;
+ /** Callback when stop recording button is clicked */
+ onStopRecording: () => void;
+ /** Callback when clear button is clicked */
+ onClear: () => void;
+}
+
+const MeetingMinutesControlButtons: React.FC<
+ MeetingMinutesControlButtonsProps
+> = ({
+ isRecording,
+ hasTranscriptText,
+ transcriptText,
+ onStartRecording,
+ onStopRecording,
+ onClear,
+}) => {
+ const { t } = useTranslation();
+
+ return (
+
+ {/* Copy and Send buttons - show when transcript exists */}
+ {hasTranscriptText && (
+ <>
+
+
+ >
+ )}
+
+ {/* Recording control buttons */}
+ {!isRecording ? (
+
+
+ {t('transcribe.start_recording')}
+
+ ) : (
+
+
+ {t('transcribe.stop_recording')}
+
+ )}
+
+ {/* Clear button */}
+
+ {t('common.clear')}
+
+
+ );
+};
+
+export default MeetingMinutesControlButtons;
diff --git a/packages/web/src/components/MeetingMinutes/MeetingMinutesGeneration.tsx b/packages/web/src/components/MeetingMinutes/MeetingMinutesGeneration.tsx
index 179cc0c43..bdc1ed7f7 100644
--- a/packages/web/src/components/MeetingMinutes/MeetingMinutesGeneration.tsx
+++ b/packages/web/src/components/MeetingMinutes/MeetingMinutesGeneration.tsx
@@ -6,43 +6,36 @@ import React, {
useEffect,
} from 'react';
import { useTranslation } from 'react-i18next';
-import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
-import queryString from 'query-string';
+import { PiGearSix } from 'react-icons/pi';
import Button from '../Button';
import ButtonCopy from '../ButtonCopy';
import ButtonIcon from '../ButtonIcon';
-import Select from '../Select';
-import Switch from '../Switch';
-import Textarea from '../Textarea';
import Markdown from '../Markdown';
-import { PiPencilLine, PiCaretRight, PiCaretLeft } from 'react-icons/pi';
-import useMeetingMinutes, {
- MeetingMinutesStyle,
-} from '../../hooks/useMeetingMinutes';
+import MeetingMinutesSettingsModal from './MeetingMinutesSettingsModal';
+import useMeetingMinutes from '../../hooks/useMeetingMinutes';
import { MODELS } from '../../hooks/useModel';
+import { MeetingMinutesParams, DiagramOption } from '../../prompts';
+import { claudePrompter } from '../../prompts/claude';
interface MeetingMinutesGenerationProps {
/** Current transcript text to generate minutes from */
transcriptText: string;
- /** Whether the panel is collapsed */
- isCollapsed: boolean;
- /** Handler for toggle collapse state */
- onToggleCollapse: () => void;
}
const MeetingMinutesGeneration: React.FC = ({
transcriptText,
- isCollapsed,
- onToggleCollapse,
}) => {
const { t } = useTranslation();
- const navigate = useNavigate();
const countdownIntervalRef = useRef(null);
const shouldGenerateRef = useRef(false);
+ // Modal state
+ const [isSettingsOpen, setIsSettingsOpen] = useState(false);
+
// Internal state management
- const [minutesStyle, setMinutesStyle] = useState('faq');
+ const [minutesStyle, setMinutesStyle] =
+ useState('summary');
const [customPrompt, setCustomPrompt] = useState('');
const [autoGenerate, setAutoGenerate] = useState(false);
const [generationFrequency, setGenerationFrequency] = useState(5);
@@ -50,6 +43,26 @@ const MeetingMinutesGeneration: React.FC = ({
const [generatedMinutes, setGeneratedMinutes] = useState('');
const [countdownSeconds, setCountdownSeconds] = useState(0);
+ // Diagram options for 'diagram' style
+ const [diagramOptions, setDiagramOptions] = useState([
+ 'mindmap',
+ ]);
+
+ // Toggle diagram option
+ const toggleDiagramOption = useCallback((option: DiagramOption) => {
+ setDiagramOptions((prev) => {
+ if (prev.includes(option)) {
+ // Don't allow removing the last option
+ if (prev.length === 1) {
+ return prev;
+ }
+ return prev.filter((o) => o !== option);
+ } else {
+ return [...prev, option];
+ }
+ });
+ }, []);
+
// Model selection
const { modelIds: availableModels, modelDisplayName } = MODELS;
const [modelId, setModelId] = useState(availableModels[0] || '');
@@ -65,7 +78,8 @@ const MeetingMinutesGeneration: React.FC = ({
autoGenerateSessionTimestamp,
setGeneratedMinutes,
() => {}, // Empty function for setLastProcessedTranscript
- () => {} // Empty function for setLastGeneratedTime
+ () => {}, // Empty function for setLastGeneratedTime
+ minutesStyle === 'diagram' ? diagramOptions : undefined
);
// Text existence check
@@ -73,6 +87,21 @@ const MeetingMinutesGeneration: React.FC = ({
return transcriptText.trim() !== '';
}, [transcriptText]);
+ // Get style label for display
+ const styleLabel = useMemo(() => {
+ const styleOptions: Record = {
+ summary: t('meetingMinutes.style_summary'),
+ detail: t('meetingMinutes.style_detail'),
+ faq: t('meetingMinutes.style_faq'),
+ transcription: t('meetingMinutes.style_transcription'),
+ diagram: t('meetingMinutes.style_diagram'),
+ newspaper: t('meetingMinutes.style_newspaper'),
+ whiteboard: t('meetingMinutes.style_whiteboard'),
+ custom: t('meetingMinutes.style_custom'),
+ };
+ return styleOptions[minutesStyle];
+ }, [minutesStyle, t]);
+
// Watch for generation signal and trigger generation
useEffect(() => {
if (
@@ -82,13 +111,18 @@ const MeetingMinutesGeneration: React.FC = ({
) {
if (!minutesLoading) {
shouldGenerateRef.current = false;
- generateMinutes(transcriptText, modelId, (status) => {
- if (status === 'success') {
- toast.success(t('meetingMinutes.generation_success'));
- } else if (status === 'error') {
- toast.error(t('meetingMinutes.generation_error'));
- }
- });
+ generateMinutes(
+ transcriptText,
+ modelId,
+ (status) => {
+ if (status === 'success') {
+ toast.success(t('meetingMinutes.generation_success'));
+ } else if (status === 'error') {
+ toast.error(t('meetingMinutes.generation_error'));
+ }
+ },
+ generatedMinutes
+ );
} else {
shouldGenerateRef.current = false;
}
@@ -101,6 +135,7 @@ const MeetingMinutesGeneration: React.FC = ({
generateMinutes,
modelId,
t,
+ generatedMinutes,
]);
// Auto-generation countdown setup
@@ -147,13 +182,18 @@ const MeetingMinutesGeneration: React.FC = ({
}
if (hasTranscriptText && !minutesLoading) {
- generateMinutes(transcriptText, modelId, (status) => {
- if (status === 'success') {
- toast.success(t('meetingMinutes.generation_success'));
- } else if (status === 'error') {
- toast.error(t('meetingMinutes.generation_error'));
- }
- });
+ generateMinutes(
+ transcriptText,
+ modelId,
+ (status) => {
+ if (status === 'success') {
+ toast.success(t('meetingMinutes.generation_success'));
+ } else if (status === 'error') {
+ toast.error(t('meetingMinutes.generation_error'));
+ }
+ },
+ generatedMinutes
+ );
}
}, [
hasTranscriptText,
@@ -164,6 +204,7 @@ const MeetingMinutesGeneration: React.FC = ({
t,
minutesStyle,
customPrompt,
+ generatedMinutes,
]);
// Clear minutes handler
@@ -171,181 +212,100 @@ const MeetingMinutesGeneration: React.FC = ({
clearMinutes();
}, [clearMinutes]);
+ // Get system prompt for preview
+ const getSystemPrompt = useCallback(
+ (
+ style: MeetingMinutesParams['style'],
+ customPromptOverride?: string,
+ diagramOptionsOverride?: DiagramOption[]
+ ) => {
+ const params: MeetingMinutesParams = {
+ style,
+ customPrompt: customPromptOverride || customPrompt,
+ diagramOptions: diagramOptionsOverride || diagramOptions,
+ };
+ return claudePrompter.meetingMinutesPrompt(params);
+ },
+ [customPrompt, diagramOptions]
+ );
+
return (
-
- {isCollapsed ? (
- // Collapsed UI
-
-
+
+ {/* Compact header with settings button and action buttons */}
+
+
+
setIsSettingsOpen(true)}>
+
+
+ {/* eslint-disable-next-line @shopify/jsx-no-hardcoded-content */}
+
setIsSettingsOpen(true)}
+ className="cursor-pointer text-sm text-gray-600">
+ {`${styleLabel} / ${modelDisplayName(modelId)}`}
+
+ {autoGenerate && countdownSeconds > 0 && (
+ // eslint-disable-next-line @shopify/jsx-no-hardcoded-content
+
+ {`(${t('meetingMinutes.next_generation')}${t('common.colon')} ${Math.floor(countdownSeconds / 60)}:${(countdownSeconds % 60).toString().padStart(2, '0')})`}
+
+ )}
- ) : (
- // Expanded UI
-
- {/* Header with collapse button */}
-
-
-
- {t('common.collapse')}
-
-
-
- {/* Meeting Minutes Configuration */}
-
-
-
-
- {t('meetingMinutes.style')}
-
-
- setMinutesStyle(value as typeof minutesStyle)
- }
- options={[
- {
- value: 'faq',
- label: t('meetingMinutes.style_faq'),
- },
- {
- value: 'summary',
- label: t('meetingMinutes.style_summary'),
- },
- {
- value: 'detail',
- label: t('meetingMinutes.style_detail'),
- },
- {
- value: 'custom',
- label: t('meetingMinutes.style_custom'),
- },
- ]}
- />
-
-
-
-
- {t('meetingMinutes.model')}
-
- ({
- value: id,
- label: modelDisplayName(id),
- }))}
- />
-
-
-
- {minutesStyle === 'custom' && (
-
-
- {t('meetingMinutes.custom_prompt')}
-
-
-
- )}
-
- {/* Auto-generation controls */}
-
-
- {autoGenerate && (
-
-
- {t('meetingMinutes.generation_frequency')}
-
-
setGenerationFrequency(Number(value))}
- options={[
- { value: '1', label: t('meetingMinutes.frequency_1min') },
- { value: '3', label: t('meetingMinutes.frequency_3min') },
- { value: '5', label: t('meetingMinutes.frequency_5min') },
- {
- value: '10',
- label: t('meetingMinutes.frequency_10min'),
- },
- ]}
- />
- {countdownSeconds > 0 && (
-
- {t('meetingMinutes.next_generation')}
- {t('common.colon')} {Math.floor(countdownSeconds / 60)}
- {t('common.colon')}
- {(countdownSeconds % 60).toString().padStart(2, '0')}
-
- )}
-
- )}
-
-
+
+
+ {t('meetingMinutes.generate')}
+
+
+ {t('meetingMinutes.clear_minutes')}
+
+
+
- {/* Generation buttons */}
-
-
- {t('meetingMinutes.generate')}
-
-
- {t('meetingMinutes.clear_minutes')}
-
+ {/* Generated minutes display - now takes most of the space */}
+
+
+
+ {t('meetingMinutes.generated_minutes')}
-
- {/* Generated minutes display */}
{generatedMinutes && (
-
-
-
-
- {t('meetingMinutes.generated_minutes')}
-
-
-
-
-
{
- navigate(
- `/edit?${queryString.stringify({
- content: generatedMinutes,
- })}`
- );
- }}>
-
-
-
-
-
- {generatedMinutes}
-
+
+
)}
- )}
+
+ {generatedMinutes ? (
+
{generatedMinutes}
+ ) : (
+
+ {t('meetingMinutes.minutes_placeholder')}
+
+ )}
+
+
+
+ {/* Settings Modal */}
+
setIsSettingsOpen(false)}
+ minutesStyle={minutesStyle}
+ setMinutesStyle={setMinutesStyle}
+ modelId={modelId}
+ setModelId={setModelId}
+ availableModels={availableModels}
+ modelDisplayName={modelDisplayName}
+ customPrompt={customPrompt}
+ setCustomPrompt={setCustomPrompt}
+ diagramOptions={diagramOptions}
+ toggleDiagramOption={toggleDiagramOption}
+ autoGenerate={autoGenerate}
+ setAutoGenerate={setAutoGenerate}
+ generationFrequency={generationFrequency}
+ setGenerationFrequency={setGenerationFrequency}
+ getSystemPrompt={getSystemPrompt}
+ />
);
};
diff --git a/packages/web/src/components/MeetingMinutes/MeetingMinutesRealtimeTranslation.tsx b/packages/web/src/components/MeetingMinutes/MeetingMinutesRealtimeTranslation.tsx
index c414d62a2..410d78df3 100644
--- a/packages/web/src/components/MeetingMinutes/MeetingMinutesRealtimeTranslation.tsx
+++ b/packages/web/src/components/MeetingMinutes/MeetingMinutesRealtimeTranslation.tsx
@@ -8,18 +8,11 @@ import React, {
import { useTranslation } from 'react-i18next';
import { LanguageCode } from '@aws-sdk/client-transcribe-streaming';
import { Transcript } from 'generative-ai-use-cases';
-import Button from '../Button';
-import ButtonCopy from '../ButtonCopy';
-import ButtonSendToUseCase from '../ButtonSendToUseCase';
import Select from '../Select';
-import Switch from '../Switch';
-import RangeSlider from '../RangeSlider';
-import ExpandableField from '../ExpandableField';
import Textarea from '../Textarea';
-import ScreenAudioToggle from '../ScreenAudioToggle';
-import MicAudioToggle from '../MicAudioToggle';
import MeetingMinutesTranscriptSegment from './MeetingMinutesTranscriptSegment';
-import { PiStopCircleBold, PiMicrophoneBold } from 'react-icons/pi';
+import MeetingMinutesSettingsPanel from './MeetingMinutesSettingsPanel';
+import MeetingMinutesControlButtons from './MeetingMinutesControlButtons';
import useMicrophone from '../../hooks/useMicrophone';
import useScreenAudio from '../../hooks/useScreenAudio';
import useRealtimeTranslation from '../../hooks/useRealtimeTranslation';
@@ -652,20 +645,6 @@ const MeetingMinutesRealtimeTranslation: React.FC<
// Recording states
const isRecording = micRecording || screenRecording;
- // Calculate responsive transcript container height
- const getTranscriptHeight = useCallback(() => {
- const baseClasses =
- 'w-full overflow-y-auto rounded border border-black/30 p-1.5 min-h-64';
-
- if (isRecording) {
- // Recording: Settings hidden, more space available
- return `${baseClasses} max-h-72 sm:max-h-80 lg:max-h-[60vh]`;
- } else {
- // Not recording: Settings visible, less space available
- return `${baseClasses} max-h-56 sm:max-h-64 lg:max-h-[30vh]`;
- }
- }, [isRecording]);
-
// Clear function
const handleClear = useCallback(() => {
setRealtimeSegments([]);
@@ -764,220 +743,154 @@ const MeetingMinutesRealtimeTranslation: React.FC<
clearScreenTranscripts,
]);
+ // Stop transcription
+ const handleStopRecording = useCallback(() => {
+ stopMicTranscription();
+ stopScreenTranscription();
+ }, [stopMicTranscription, stopScreenTranscription]);
+
return (
-
- {/* Realtime Translation Content */}
-
-
-
- {isRecording ? (
-
{
- stopMicTranscription();
- stopScreenTranscription();
- }}>
-
- {t('transcribe.stop_recording')}
-
- ) : (
-
-
- {t('transcribe.start_recording')}
-
- )}
-
- {!isRecording && (
-
-
+ {/* Settings Panel */}
+
+ {/* Translation-specific settings */}
+
+ {/* Languages */}
+
+
+
+ {translationType === 'bidirectional'
+ ? t('meetingMinutes.language_1')
+ : t('meetingMinutes.transcription_language')}
+
+
+
- /g,
- '\n'
- )}
+
+
+
+
+ {translationType === 'bidirectional'
+ ? t('meetingMinutes.language_2')
+ : t('meetingMinutes.translation_language')}
+
+
+
- )}
+
-
- {/* Language Selection and Translation Settings */}
- {!isRecording && (
-
-
- {/* Left column: Translation type and model */}
-
-
-
- {t('meetingMinutes.translation_type')}
-
-
-
-
- ({
- value: modelId,
- label: MODELS.modelDisplayName(modelId),
- }))}
- />
-
-
+ {/* Translation Type */}
+
+
+ {t('meetingMinutes.translation_type')}
+
+
+
+
+
- {/* Right column: Languages */}
- {realtimeTranslationEnabled && (
-
-
-
- {translationType === 'bidirectional'
- ? t('meetingMinutes.language_1')
- : t('meetingMinutes.transcription_language')}
-
-
-
-
-
- {translationType === 'bidirectional'
- ? t('meetingMinutes.language_2')
- : t('meetingMinutes.translation_language')}
-
-
-
-
- )}
+ {/* Translation Model */}
+
+
+ {t('meetingMinutes.translation_model')}
+
+
+ ({
+ value: modelId,
+ label: MODELS.modelDisplayName(modelId),
+ }))}
+ fullWidth
+ notItem
+ />
- )}
+
{/* Translation Context - Only show when real-time translation is ON and recording */}
{realtimeTranslationEnabled && isRecording && (
-
-
-
- {t('translate.contextHelp')}
-
+
+
+ {t('translate.contextHelp')}
- {/* User-defined Context */}
-
+
{t('translate.userDefinedContext')}
-
- {/* System-generated Context */}
-
+
{t('translate.systemGeneratedContext')}
)}
- {/* Speaker Recognition Parameters */}
- {!isRecording && (
-
-
-
- {speakerLabel && (
-
- )}
-
- {speakerLabel && (
-
-
-
- )}
-
- )}
-
{/* Screen Audio Error Display */}
{screenAudioError && (
-
+
{t('meetingMinutes.screen_audio_error')}
{t('common.colon')} {screenAudioError}
)}
- {/* Clear Button */}
-
-
- {t('common.clear')}
-
-
-
{/* Transcript Panel */}
-
-
+
+
{t('meetingMinutes.transcript')}
- {hasTranscriptText && (
-
-
-
-
- )}
+
- {realtimeSegments.length === 0 ? (
-
- {t('transcribe.result_placeholder')}
+ className="relative min-h-0 flex-1 overflow-y-auto rounded border border-black/30 p-1.5">
+ {realtimeSegments.length === 0 && !isRecording ? (
+
+
+ {t('transcribe.result_placeholder')}
+
) : (
[...realtimeSegments]
@@ -1008,6 +923,14 @@ const MeetingMinutesRealtimeTranslation: React.FC<
const isNewSession =
prevSegment && segment.sessionId !== prevSegment.sessionId;
+ // Find the translation target for this segment
+ const translationTarget = getTranslationTarget(
+ translationType,
+ segment.languageCode,
+ primaryLanguage,
+ secondaryLanguage
+ );
+
return (
@@ -1026,19 +949,19 @@ const MeetingMinutesRealtimeTranslation: React.FC<
speakerMapping={speakerMapping}
isPartial={segment.isPartial}
formatTime={formatTime}
- translation={segment.translationSegments
- .map((ts) => ts.translation || '')
- .join('')}
+ translation={
+ segment.translationSegments
+ .map((ts) => ts.translation)
+ .filter(Boolean)
+ .join(' ') || ''
+ }
translationSegments={segment.translationSegments}
- isTranslating={false}
+ isTranslating={segment.translationSegments.some(
+ (ts) => ts.needsTranslation && !ts.translation
+ )}
translationEnabled={realtimeTranslationEnabled}
detectedLanguage={segment.languageCode}
- translationTarget={getTranslationTarget(
- translationType,
- segment.languageCode,
- primaryLanguage,
- secondaryLanguage
- )}
+ translationTarget={translationTarget}
isBidirectional={translationType === 'bidirectional'}
/>
diff --git a/packages/web/src/components/MeetingMinutes/MeetingMinutesSettingsModal.tsx b/packages/web/src/components/MeetingMinutes/MeetingMinutesSettingsModal.tsx
new file mode 100644
index 000000000..fe6f78ada
--- /dev/null
+++ b/packages/web/src/components/MeetingMinutes/MeetingMinutesSettingsModal.tsx
@@ -0,0 +1,299 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { BiAbacus } from 'react-icons/bi';
+import { FaTimeline } from 'react-icons/fa6';
+import { PiCheck } from 'react-icons/pi';
+import { RiMindMap } from 'react-icons/ri';
+import { VscTypeHierarchy } from 'react-icons/vsc';
+import Button from '../Button';
+import ModalDialog from '../ModalDialog';
+import Select from '../Select';
+import Switch from '../Switch';
+import Textarea from '../Textarea';
+import { MeetingMinutesParams, DiagramOption } from '../../prompts';
+import { IconType } from 'react-icons';
+
+interface DiagramOptionInfo {
+ id: DiagramOption;
+ icon: IconType;
+ labelKey: string;
+}
+
+const DIAGRAM_OPTIONS: DiagramOptionInfo[] = [
+ {
+ id: 'mindmap',
+ icon: RiMindMap,
+ labelKey: 'meetingMinutes.diagram_mindmap',
+ },
+ {
+ id: 'flowchart',
+ icon: VscTypeHierarchy,
+ labelKey: 'meetingMinutes.diagram_flowchart',
+ },
+ {
+ id: 'timeline',
+ icon: FaTimeline,
+ labelKey: 'meetingMinutes.diagram_timeline',
+ },
+ {
+ id: 'sequence',
+ icon: BiAbacus,
+ labelKey: 'meetingMinutes.diagram_sequence',
+ },
+];
+
+interface DiagramOptionCardProps {
+ option: DiagramOptionInfo;
+ isSelected: boolean;
+ onToggle: () => void;
+}
+
+const DiagramOptionCard: React.FC
= ({
+ option,
+ isSelected,
+ onToggle,
+}) => {
+ const { t } = useTranslation();
+
+ return (
+
+ {isSelected && (
+
+ )}
+
+ {React.createElement(option.icon, {
+ size: '1.5rem',
+ className: `mx-auto ${isSelected ? 'text-gray-900' : 'text-gray-500'}`,
+ })}
+
+ {t(option.labelKey)}
+
+
+
+ );
+};
+
+interface MeetingMinutesSettingsModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ // Style settings
+ minutesStyle: MeetingMinutesParams['style'];
+ setMinutesStyle: (style: MeetingMinutesParams['style']) => void;
+ // Model settings
+ modelId: string;
+ setModelId: (modelId: string) => void;
+ availableModels: string[];
+ modelDisplayName: (modelId: string) => string;
+ // Custom prompt
+ customPrompt: string;
+ setCustomPrompt: (prompt: string) => void;
+ // Diagram options
+ diagramOptions: DiagramOption[];
+ toggleDiagramOption: (option: DiagramOption) => void;
+ // Auto-generation
+ autoGenerate: boolean;
+ setAutoGenerate: (value: boolean) => void;
+ generationFrequency: number;
+ setGenerationFrequency: (value: number) => void;
+ // System prompt preview
+ getSystemPrompt: (
+ style: MeetingMinutesParams['style'],
+ customPrompt?: string,
+ diagramOptions?: DiagramOption[]
+ ) => string;
+}
+
+const MeetingMinutesSettingsModal: React.FC<
+ MeetingMinutesSettingsModalProps
+> = ({
+ isOpen,
+ onClose,
+ minutesStyle,
+ setMinutesStyle,
+ modelId,
+ setModelId,
+ availableModels,
+ modelDisplayName,
+ customPrompt,
+ setCustomPrompt,
+ diagramOptions,
+ toggleDiagramOption,
+ autoGenerate,
+ setAutoGenerate,
+ generationFrequency,
+ setGenerationFrequency,
+ getSystemPrompt,
+}) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {/* Style and Model selection - horizontal layout */}
+
+ {/* Style selection */}
+
+
+ {t('meetingMinutes.style')}
+
+
+ setMinutesStyle(value as MeetingMinutesParams['style'])
+ }
+ options={[
+ {
+ value: 'summary',
+ label: t('meetingMinutes.style_summary'),
+ },
+ {
+ value: 'detail',
+ label: t('meetingMinutes.style_detail'),
+ },
+ {
+ value: 'faq',
+ label: t('meetingMinutes.style_faq'),
+ },
+ {
+ value: 'transcription',
+ label: t('meetingMinutes.style_transcription'),
+ },
+ {
+ value: 'diagram',
+ label: t('meetingMinutes.style_diagram'),
+ },
+ {
+ value: 'whiteboard',
+ label: t('meetingMinutes.style_whiteboard'),
+ },
+ {
+ value: 'newspaper',
+ label: t('meetingMinutes.style_newspaper'),
+ },
+ {
+ value: 'custom',
+ label: t('meetingMinutes.style_custom'),
+ },
+ ]}
+ />
+
+
+ {/* Model selection */}
+
+
+ {t('meetingMinutes.model')}
+
+ ({
+ value: id,
+ label: modelDisplayName(id),
+ }))}
+ />
+
+
+
+ {/* System prompt preview (when style is not 'custom') */}
+ {minutesStyle !== 'custom' && (
+
+
+
+
+ {t('meetingMinutes.view_prompt')}
+
+
+ {t('meetingMinutes.hide_prompt')}
+
+
+
+
+ {getSystemPrompt(minutesStyle, customPrompt, diagramOptions)}
+
+
+
+
+ )}
+
+ {/* Custom prompt (when style is 'custom') */}
+ {minutesStyle === 'custom' && (
+
+
+ {t('meetingMinutes.custom_prompt')}
+
+
+
+ )}
+
+ {/* Diagram options (when style is 'diagram') */}
+ {minutesStyle === 'diagram' && (
+
+
+ {t('meetingMinutes.diagram_options')}
+
+
+ {DIAGRAM_OPTIONS.map((option) => (
+ toggleDiagramOption(option.id)}
+ />
+ ))}
+
+
+ )}
+
+ {/* Auto-generation controls */}
+
+
+ {autoGenerate && (
+
+
+ {t('meetingMinutes.generation_frequency')}
+
+ setGenerationFrequency(Number(value))}
+ options={[
+ { value: '1', label: t('meetingMinutes.frequency_1min') },
+ { value: '3', label: t('meetingMinutes.frequency_3min') },
+ { value: '5', label: t('meetingMinutes.frequency_5min') },
+ {
+ value: '10',
+ label: t('meetingMinutes.frequency_10min'),
+ },
+ ]}
+ />
+
+ )}
+
+
+ {/* Close button */}
+
+ {t('common.close')}
+
+
+
+ );
+};
+
+export default MeetingMinutesSettingsModal;
diff --git a/packages/web/src/components/MeetingMinutes/MeetingMinutesSettingsPanel.tsx b/packages/web/src/components/MeetingMinutes/MeetingMinutesSettingsPanel.tsx
new file mode 100644
index 000000000..208e28c0b
--- /dev/null
+++ b/packages/web/src/components/MeetingMinutes/MeetingMinutesSettingsPanel.tsx
@@ -0,0 +1,183 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import RangeSlider from '../RangeSlider';
+import TogglePillButton from '../TogglePillButton';
+import Help from '../Help';
+import useIsMobile from '../../hooks/useIsMobile';
+import useScreenAudio from '../../hooks/useScreenAudio';
+import {
+ PiMicrophoneBold,
+ PiDesktopBold,
+ PiUsersBold,
+ PiGearBold,
+ PiCaretRightFill,
+} from 'react-icons/pi';
+
+interface MeetingMinutesSettingsPanelProps {
+ /** Whether recording is currently active (hides panel when true) */
+ isRecording: boolean;
+
+ /** Microphone audio settings */
+ enableMicAudio: boolean;
+ setEnableMicAudio: (enabled: boolean) => void;
+
+ /** Screen audio settings */
+ enableScreenAudio: boolean;
+ setEnableScreenAudio: (enabled: boolean) => void;
+
+ /** Speaker recognition settings */
+ speakerLabel: boolean;
+ setSpeakerLabel: (enabled: boolean) => void;
+
+ /** Speaker recognition parameters */
+ maxSpeakers: number;
+ setMaxSpeakers: (count: number) => void;
+ speakers: string;
+ setSpeakers: (speakers: string) => void;
+
+ /** Additional settings content (languages, translation, etc.) */
+ children?: React.ReactNode;
+}
+
+const MeetingMinutesSettingsPanel: React.FC<
+ MeetingMinutesSettingsPanelProps
+> = ({
+ isRecording,
+ enableMicAudio,
+ setEnableMicAudio,
+ enableScreenAudio,
+ setEnableScreenAudio,
+ speakerLabel,
+ setSpeakerLabel,
+ maxSpeakers,
+ setMaxSpeakers,
+ speakers,
+ setSpeakers,
+ children,
+}) => {
+ const { t } = useTranslation();
+ const isMobile = useIsMobile();
+ const { isSupported: isScreenAudioSupported } = useScreenAudio();
+
+ // Mobile-specific collapsible state
+ const [settingsExpanded, setSettingsExpanded] = React.useState(!isMobile);
+
+ // Update settings panel state when screen size changes
+ React.useEffect(() => {
+ setSettingsExpanded(!isMobile);
+ }, [isMobile]);
+
+ // Hide panel during recording
+ if (isRecording) {
+ return null;
+ }
+
+ return (
+
+ {/* Settings Header - Clickable on mobile */}
+
setSettingsExpanded(!settingsExpanded) : undefined
+ }>
+
+ {t('meetingMinutes.settings')}
+ {isMobile && (
+
+ )}
+
+
+ {/* Settings Content - Collapsible on mobile */}
+ {settingsExpanded && (
+ <>
+ {/* Input Source */}
+
+
+ {t('meetingMinutes.input_source')}
+
+
+ }
+ label={t('meetingMinutes.microphone')}
+ isEnabled={enableMicAudio}
+ onToggle={() => setEnableMicAudio(!enableMicAudio)}
+ activeColor="blue"
+ />
+ {isScreenAudioSupported && (
+ <>
+ }
+ label={t('transcribe.screen_audio')}
+ isEnabled={enableScreenAudio}
+ onToggle={() => setEnableScreenAudio(!enableScreenAudio)}
+ activeColor="blue"
+ />
+
+ >
+ )}
+
+
+
+ {/* Option */}
+
+
+ {t('meetingMinutes.option')}
+
+
+ }
+ label={t('transcribe.speaker_recognition')}
+ isEnabled={speakerLabel}
+ onToggle={() => setSpeakerLabel(!speakerLabel)}
+ activeColor="gray"
+ />
+
+
+
+ {/* Speaker Recognition Parameters (when enabled) */}
+ {speakerLabel && (
+
+
+ {t('transcribe.detailed_parameters')}
+
+
+
+
+
+
+
+ )}
+
+ {/* Additional settings passed as children */}
+ {children}
+ >
+ )}
+
+ );
+};
+
+export default MeetingMinutesSettingsPanel;
diff --git a/packages/web/src/components/MeetingMinutes/MeetingMinutesTranscription.tsx b/packages/web/src/components/MeetingMinutes/MeetingMinutesTranscription.tsx
index 5237ea7a2..3ac52f2f4 100644
--- a/packages/web/src/components/MeetingMinutes/MeetingMinutesTranscription.tsx
+++ b/packages/web/src/components/MeetingMinutes/MeetingMinutesTranscription.tsx
@@ -8,17 +8,10 @@ import React, {
import { useTranslation } from 'react-i18next';
import { LanguageCode } from '@aws-sdk/client-transcribe-streaming';
import { Transcript } from 'generative-ai-use-cases';
-import Button from '../Button';
-import ButtonCopy from '../ButtonCopy';
-import ButtonSendToUseCase from '../ButtonSendToUseCase';
import Select from '../Select';
-import Switch from '../Switch';
-import RangeSlider from '../RangeSlider';
-import ExpandableField from '../ExpandableField';
-import ScreenAudioToggle from '../ScreenAudioToggle';
-import MicAudioToggle from '../MicAudioToggle';
import MeetingMinutesTranscriptSegment from './MeetingMinutesTranscriptSegment';
-import { PiStopCircleBold, PiMicrophoneBold } from 'react-icons/pi';
+import MeetingMinutesSettingsPanel from './MeetingMinutesSettingsPanel';
+import MeetingMinutesControlButtons from './MeetingMinutesControlButtons';
import useMicrophone from '../../hooks/useMicrophone';
import useScreenAudio from '../../hooks/useScreenAudio';
@@ -276,20 +269,6 @@ const MeetingMinutesTranscription: React.FC<
// Recording states
const isRecording = micRecording || screenRecording;
- // Calculate responsive transcript container height
- const getTranscriptHeight = useCallback(() => {
- const baseClasses =
- 'w-full overflow-y-auto rounded border border-black/30 p-1.5 min-h-64';
-
- if (isRecording) {
- // Recording: Settings hidden, more space available
- return `${baseClasses} max-h-72 sm:max-h-80 lg:max-h-[60vh]`;
- } else {
- // Not recording: Settings visible, less space available
- return `${baseClasses} max-h-56 sm:max-h-64 lg:max-h-[30vh]`;
- }
- }, [isRecording]);
-
// Clear function
const handleClear = useCallback(() => {
setTranscriptionSegments([]);
@@ -353,138 +332,64 @@ const MeetingMinutesTranscription: React.FC<
clearScreenTranscripts,
]);
- return (
-
- {/* Voice Transcription Content */}
-
-
-
- {isRecording ? (
-
{
- stopMicTranscription();
- stopScreenTranscription();
- }}>
-
- {t('transcribe.stop_recording')}
-
- ) : (
-
-
- {t('transcribe.start_recording')}
-
- )}
-
- {!isRecording && (
-
-
- /g,
- '\n'
- )}
- />
-
- )}
-
-
+ // Stop transcription
+ const handleStopRecording = useCallback(() => {
+ stopMicTranscription();
+ stopScreenTranscription();
+ }, [stopMicTranscription, stopScreenTranscription]);
- {/* Language Selection */}
- {!isRecording && (
-
-
-
-
- {t('meetingMinutes.language')}
-
-
-
+ return (
+
+ {/* Settings Panel */}
+
+ {/* Language Selection - specific to transcription */}
+
+
+ {t('meetingMinutes.language')}
-
- )}
-
- {/* Speaker Recognition Parameters */}
- {!isRecording && (
-
-
-
+
- {speakerLabel && (
-
- )}
- {speakerLabel && (
-
-
- )}
-
- )}
+
+
{/* Screen Audio Error Display */}
{screenAudioError && (
-
+
{t('meetingMinutes.screen_audio_error')}
{t('common.colon')} {screenAudioError}
)}
- {/* Clear Button */}
-
-
- {t('common.clear')}
-
-
-
{/* Transcript Panel */}
-
-
+
+
{t('meetingMinutes.transcript')}
- {hasTranscriptText && (
-
-
-
-
- )}
+
- {transcriptionSegments.length === 0 ? (
-
- {t('transcribe.result_placeholder')}
+ className="relative min-h-0 flex-1 overflow-y-auto rounded border border-black/30 p-1.5">
+ {transcriptionSegments.length === 0 && !isRecording ? (
+
+
+ {t('transcribe.result_placeholder')}
+
) : (
[...transcriptionSegments]
diff --git a/packages/web/src/components/Mermaid/MermaidWithToggle.tsx b/packages/web/src/components/Mermaid/MermaidWithToggle.tsx
new file mode 100644
index 000000000..0f37e90cc
--- /dev/null
+++ b/packages/web/src/components/Mermaid/MermaidWithToggle.tsx
@@ -0,0 +1,326 @@
+import React, { useEffect, useState, memo, useCallback, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
+import { VscCode } from 'react-icons/vsc';
+import { LuNetwork } from 'react-icons/lu';
+import { IoIosClose, IoMdDownload } from 'react-icons/io';
+import { TbSvg, TbPng } from 'react-icons/tb';
+import mermaid, { MermaidConfig } from 'mermaid';
+
+import ButtonCopy from '../ButtonCopy';
+import Button from '../Button';
+import Textarea from '../Textarea';
+
+import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter';
+import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
+
+// Initialize mermaid with default config
+const defaultMermaidConfig: MermaidConfig = {
+ suppressErrorRendering: true,
+ securityLevel: 'loose',
+ fontFamily: 'monospace',
+ fontSize: 16,
+ htmlLabels: true,
+};
+mermaid.initialize(defaultMermaidConfig);
+
+// Mermaid SVG renderer component
+interface MermaidProps {
+ code: string;
+ handler?: () => void;
+}
+
+const Mermaid: React.FC
= (props) => {
+ const { t } = useTranslation();
+ const { code } = props;
+ const [svgContent, setSvgContent] = useState('');
+
+ const render = useCallback(async () => {
+ if (code) {
+ try {
+ const { svg } = await mermaid.render(`m${crypto.randomUUID()}`, code);
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(svg, 'image/svg+xml');
+ const svgElement = doc.querySelector('svg');
+
+ if (svgElement) {
+ svgElement.setAttribute('width', '100%');
+ svgElement.setAttribute('height', '100%');
+ setSvgContent(svgElement.outerHTML);
+ }
+ } catch (error) {
+ console.error(error);
+ setSvgContent(`${t('diagram.invalid_syntax')}
`);
+ }
+ }
+ }, [code, t]);
+
+ useEffect(() => {
+ render();
+ }, [code, render]);
+
+ return code ? (
+
+ ) : null;
+};
+
+// Mermaid with toggle component (diagram/code view with download, zoom, and optional edit)
+interface MermaidWithToggleProps {
+ code: string;
+ editable?: boolean;
+ onCodeChange?: (code: string) => void;
+}
+
+export const MermaidWithToggle = memo(
+ ({ code, editable = false, onCodeChange }: MermaidWithToggleProps) => {
+ const { t } = useTranslation();
+ // Start with 'code' view during streaming, auto-switch to 'diagram' when stable
+ const [viewMode, setViewMode] = useState<'diagram' | 'code'>('code');
+ const [zoom, setZoom] = useState(false);
+ const [editedCode, setEditedCode] = useState(code);
+ const prevCodeRef = useRef(code);
+ const timerRef = useRef(null);
+
+ // Sync editedCode when code prop changes
+ useEffect(() => {
+ setEditedCode(code);
+ }, [code]);
+
+ // Auto-switch to diagram view when code becomes stable (no changes for 500ms)
+ useEffect(() => {
+ // If code has changed
+ if (code !== prevCodeRef.current) {
+ prevCodeRef.current = code;
+
+ // Clear existing timer
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ }
+
+ // If no changes for 500ms, consider it stable and switch to diagram
+ timerRef.current = setTimeout(() => {
+ setViewMode('diagram');
+ }, 500);
+ }
+
+ return () => {
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ }
+ };
+ }, [code]);
+
+ // Handle escape key for zoom
+ useEffect(() => {
+ const handleEsc = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ setZoom(false);
+ }
+ };
+ window.addEventListener('keydown', handleEsc);
+ return () => window.removeEventListener('keydown', handleEsc);
+ }, []);
+
+ // Download as SVG
+ const downloadAsSVG = useCallback(async () => {
+ try {
+ const { svg } = await mermaid.render('download-svg', editedCode);
+ const blob = new Blob([svg], { type: 'image/svg+xml' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `diagram_${new Date().getTime()}.svg`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ } catch (error) {
+ console.error(t('diagram.svg_error'), error);
+ }
+ }, [editedCode, t]);
+
+ // Download as PNG
+ const downloadAsPNG = useCallback(async () => {
+ try {
+ const { svg } = await mermaid.render('download-png', editedCode);
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ const parser = new DOMParser();
+ const svgDoc = parser.parseFromString(svg, 'image/svg+xml');
+ const svgElement = svgDoc.querySelector('svg');
+ if (!(svgElement instanceof SVGSVGElement)) return;
+
+ const viewBox = svgElement
+ .getAttribute('viewBox')
+ ?.split(' ')
+ .map(Number) || [0, 0, 0, 0];
+ const width = Math.max(svgElement.width.baseVal.value || 0, viewBox[2]);
+ const height = Math.max(
+ svgElement.height.baseVal.value || 0,
+ viewBox[3]
+ );
+
+ const scale = 2;
+ canvas.width = width * scale;
+ canvas.height = height * scale;
+
+ const wrappedSvg = `
+
+
+ ${svg}
+
+ `;
+
+ const svgBase64 = btoa(unescape(encodeURIComponent(wrappedSvg)));
+ const img = new Image();
+ img.src = 'data:image/svg+xml;base64,' + svgBase64;
+
+ await new Promise((resolve, reject) => {
+ img.onload = resolve;
+ img.onerror = reject;
+ });
+
+ ctx.fillStyle = 'white';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.scale(scale, scale);
+ ctx.drawImage(img, 0, 0, width, height);
+
+ const link = document.createElement('a');
+ link.download = `diagram_${new Date().getTime()}.png`;
+ link.href = canvas.toDataURL('image/png', 1.0);
+ link.click();
+ } catch (error) {
+ console.error(t('diagram.png_error'), error);
+ }
+ }, [editedCode, t]);
+
+ // Handle code edit
+ const handleCodeChange = useCallback(
+ (newCode: string) => {
+ setEditedCode(newCode);
+ onCodeChange?.(newCode);
+ },
+ [onCodeChange]
+ );
+
+ return (
+ <>
+
+ {/* Toggle header */}
+
+
+ {/* View mode toggle */}
+
+
setViewMode('diagram')}>
+
+ {t('diagram.show_diagram')}
+
+
setViewMode('code')}>
+
+ {t('diagram.show_code')}
+
+
+
+ {/* Download buttons */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Content area */}
+
+
+ setZoom(true)} />
+
+
+ {editable ? (
+
+
+
+ ) : (
+
+ {editedCode}
+
+ )}
+
+
+
+
+ {/* Zoom modal */}
+ {zoom && (
+ <>
+ setZoom(false)}
+ />
+
e.stopPropagation()}>
+
+ setZoom(false)}>
+
+
+
+
+
+
+
+ >
+ )}
+ >
+ );
+ }
+);
diff --git a/packages/web/src/components/TogglePillButton.tsx b/packages/web/src/components/TogglePillButton.tsx
new file mode 100644
index 000000000..af5cc99ee
--- /dev/null
+++ b/packages/web/src/components/TogglePillButton.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import { BaseProps } from '../@types/common';
+
+type Props = BaseProps & {
+ /** Icon element to display */
+ icon: React.ReactNode;
+ /** Button label text */
+ label: string;
+ /** Whether the button is currently enabled/active */
+ isEnabled: boolean;
+ /** Callback when button is toggled */
+ onToggle: () => void;
+ /** Color scheme when enabled - 'blue' for primary actions, 'gray' for secondary */
+ activeColor?: 'blue' | 'gray';
+};
+
+const TogglePillButton: React.FC
= ({
+ icon,
+ label,
+ isEnabled,
+ onToggle,
+ activeColor = 'blue',
+ className = '',
+}) => {
+ const activeStyles =
+ activeColor === 'blue'
+ ? 'bg-blue-500 text-white'
+ : 'bg-gray-700 text-white';
+
+ const inactiveStyles = 'bg-gray-100 text-gray-700 hover:bg-gray-200';
+
+ return (
+
+ {icon}
+ {label}
+
+ );
+};
+
+export default TogglePillButton;
diff --git a/packages/web/src/hooks/useIsMobile.ts b/packages/web/src/hooks/useIsMobile.ts
new file mode 100644
index 000000000..cd4af19b2
--- /dev/null
+++ b/packages/web/src/hooks/useIsMobile.ts
@@ -0,0 +1,30 @@
+import { useState, useEffect } from 'react';
+
+/**
+ * Hook to detect mobile screen size
+ * Returns true for screens smaller than 640px (Tailwind's sm breakpoint)
+ */
+const useIsMobile = (breakpoint: number = 640): boolean => {
+ const [isMobile, setIsMobile] = useState(false);
+
+ useEffect(() => {
+ const checkIsMobile = () => {
+ setIsMobile(window.innerWidth < breakpoint);
+ };
+
+ // Initial check
+ checkIsMobile();
+
+ // Add event listener
+ window.addEventListener('resize', checkIsMobile);
+
+ // Cleanup
+ return () => {
+ window.removeEventListener('resize', checkIsMobile);
+ };
+ }, [breakpoint]);
+
+ return isMobile;
+};
+
+export default useIsMobile;
diff --git a/packages/web/src/hooks/useMeetingMinutes.ts b/packages/web/src/hooks/useMeetingMinutes.ts
index 9f9a0a464..7a74130d5 100644
--- a/packages/web/src/hooks/useMeetingMinutes.ts
+++ b/packages/web/src/hooks/useMeetingMinutes.ts
@@ -1,22 +1,17 @@
import { useState, useCallback } from 'react';
import useChatApi from './useChatApi';
import { MODELS } from './useModel';
-import { getPrompter } from '../prompts';
+import { getPrompter, MeetingMinutesParams, DiagramOption } from '../prompts';
import { UnrecordedMessage, Model } from 'generative-ai-use-cases';
-export type MeetingMinutesStyle =
- | 'faq'
- | 'newspaper'
- | 'transcription'
- | 'custom';
-
export const useMeetingMinutes = (
- minutesStyle: MeetingMinutesStyle,
+ minutesStyle: MeetingMinutesParams['style'],
customPrompt: string,
autoGenerateSessionTimestamp: number | null,
setGeneratedMinutes: (minutes: string) => void,
setLastProcessedTranscript: (transcript: string) => void,
- setLastGeneratedTime: (time: Date | null) => void
+ setLastGeneratedTime: (time: Date | null) => void,
+ diagramOptions?: DiagramOption[]
) => {
const { predictStream } = useChatApi();
const { modelIds: availableModels, textModels } = MODELS;
@@ -31,7 +26,8 @@ export const useMeetingMinutes = (
onGenerate?: (
status: 'generating' | 'success' | 'error',
data?: { message?: string; minutes?: string }
- ) => void
+ ) => void,
+ existingMinutes?: string
) => {
if (!transcript || transcript.trim() === '') return;
@@ -53,6 +49,7 @@ export const useMeetingMinutes = (
: prompter.meetingMinutesPrompt({
style: minutesStyle,
customPrompt,
+ diagramOptions,
});
const messages: UnrecordedMessage[] = [
@@ -73,7 +70,12 @@ export const useMeetingMinutes = (
});
let fullResponse = '';
- setGeneratedMinutes('');
+ const hasExisting = existingMinutes && existingMinutes.trim() !== '';
+
+ // Only clear if no existing text (first generation)
+ if (!hasExisting) {
+ setGeneratedMinutes('');
+ }
for await (const chunk of stream) {
if (chunk) {
@@ -85,7 +87,10 @@ export const useMeetingMinutes = (
const payload = JSON.parse(c) as { text: string };
if (payload.text && payload.text.length > 0) {
fullResponse += payload.text;
- setGeneratedMinutes(fullResponse);
+ // Only update during streaming if no existing text
+ if (!hasExisting) {
+ setGeneratedMinutes(fullResponse);
+ }
}
} catch (error) {
// Skip invalid JSON chunks
@@ -96,6 +101,11 @@ export const useMeetingMinutes = (
}
}
+ // If existing text was present, update only after completion
+ if (hasExisting) {
+ setGeneratedMinutes(fullResponse);
+ }
+
setLastProcessedTranscript(transcript);
setLastGeneratedTime(new Date());
onGenerate?.('success', { minutes: fullResponse });
@@ -110,6 +120,7 @@ export const useMeetingMinutes = (
[
minutesStyle,
customPrompt,
+ diagramOptions,
predictStream,
textModels,
autoGenerateSessionTimestamp,
diff --git a/packages/web/src/pages/GenerateDiagramPage.tsx b/packages/web/src/pages/GenerateDiagramPage.tsx
index 2f7256130..dd39528f1 100644
--- a/packages/web/src/pages/GenerateDiagramPage.tsx
+++ b/packages/web/src/pages/GenerateDiagramPage.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useMemo, lazy, Suspense } from 'react';
+import React, { useCallback, useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import Card from '../components/Card';
import Button from '../components/Button';
@@ -10,7 +10,7 @@ import { MODELS } from '../hooks/useModel';
import queryString from 'query-string';
import useDiagram from '../hooks/useDiagram';
import { DiagramPageQueryParams } from '../@types/navigate';
-import Markdown from '../components/Markdown';
+import { MermaidWithToggle } from '../components/Markdown';
import { RiRobot2Line, RiMindMap } from 'react-icons/ri';
import { BiAbacus } from 'react-icons/bi';
import { BsDiagram3 } from 'react-icons/bs';
@@ -32,8 +32,6 @@ import {
} from 'react-icons/tb';
import { useTranslation } from 'react-i18next';
-const DiagramRenderer = lazy(() => import('../components/DiagramRenderer'));
-
type StateType = {
content: string;
setContent: (s: string) => void;
@@ -650,22 +648,13 @@ const GenerateDiagramPage: React.FC = () => {
{DiagramData[diagramType as keyof typeof DiagramData]
?.title || t('diagram.chart')}
- {loading ? (
-
-
- {['```mermaid', diagramCode, '```'].join('\n')}
-
-
- ) : (
-
- {t('common.loading')}
}>
-
-
-
- )}
+
+
+
)}
diff --git a/packages/web/src/pages/MeetingMinutesPage.tsx b/packages/web/src/pages/MeetingMinutesPage.tsx
index 9884f1e0e..60e49c637 100644
--- a/packages/web/src/pages/MeetingMinutesPage.tsx
+++ b/packages/web/src/pages/MeetingMinutesPage.tsx
@@ -1,10 +1,12 @@
-import React, { useState, useCallback, useMemo } from 'react';
+import React, { useState, useCallback, useMemo, useEffect } from 'react';
import Card from '../components/Card';
import {
PiMicrophoneBold,
PiPencilLine,
PiPaperclip,
PiTranslateBold,
+ PiFileText,
+ PiColumnsBold,
} from 'react-icons/pi';
import MeetingMinutesTranscription from '../components/MeetingMinutes/MeetingMinutesTranscription';
import MeetingMinutesRealtimeTranslation from '../components/MeetingMinutes/MeetingMinutesRealtimeTranslation';
@@ -47,13 +49,18 @@ export interface CommonTranscriptProps {
disableClear: boolean;
}
+// Panel type for view toggle
+type ViewPanel = 'transcription' | 'both' | 'generation';
+
const MeetingMinutesPage: React.FC = () => {
const { t } = useTranslation();
// State management
const [inputMethod, setInputMethod] = useState
('transcription');
- const [isGenerationPanelCollapsed, setIsGenerationPanelCollapsed] =
- useState(false);
+ // Active panel for view toggle (default: 'both' for desktop)
+ const [activePanel, setActivePanel] = useState('both');
+ // Track if screen is large (lg breakpoint: 1024px)
+ const [isLargeScreen, setIsLargeScreen] = useState(true);
const [transcriptTexts, setTranscriptTexts] = useState({
transcription: '',
direct: '',
@@ -123,106 +130,215 @@ const MeetingMinutesPage: React.FC = () => {
[]
);
+ // Memoized callback for recording state changes (prevents infinite loop)
+ const handleTranscriptionRecordingStateChange = useCallback(
+ (state: { micRecording: boolean; screenRecording: boolean }) => {
+ setTranscriptionRecording(state);
+ },
+ []
+ );
+
+ const handleRealtimeTranslationRecordingStateChange = useCallback(
+ (state: { micRecording: boolean; screenRecording: boolean }) => {
+ setRealtimeTranslationRecording(state);
+ },
+ []
+ );
+
// Get current transcript text
const currentTranscriptText = transcriptTexts[inputMethod];
- // Toggle generation panel collapse state
- const toggleGenerationPanelCollapse = () => {
- setIsGenerationPanelCollapsed(!isGenerationPanelCollapsed);
- };
+ // Monitor screen size changes for responsive behavior
+ useEffect(() => {
+ const mediaQuery = window.matchMedia('(min-width: 1024px)');
+
+ const handleChange = (e: MediaQueryListEvent | MediaQueryList) => {
+ setIsLargeScreen(e.matches);
+ if (!e.matches && activePanel === 'both') {
+ // When screen becomes small and 'both' is selected, switch to 'transcription'
+ setActivePanel('transcription');
+ }
+ };
+
+ // Initial check
+ handleChange(mediaQuery);
+ mediaQuery.addEventListener('change', handleChange);
+
+ return () => mediaQuery.removeEventListener('change', handleChange);
+ }, [activePanel]);
+
+ // Handle panel selection with screen size consideration
+ const handlePanelChange = useCallback(
+ (panel: ViewPanel) => {
+ if (panel === 'both' && !isLargeScreen) {
+ // On small screens, 'both' is not available, fallback to 'transcription'
+ setActivePanel('transcription');
+ } else {
+ setActivePanel(panel);
+ }
+ },
+ [isLargeScreen]
+ );
return (
-
+
blocker.reset?.()}
onConfirm={() => blocker.proceed?.()}
/>
- {/* Title Header - Always fixed at top */}
-
- {t('meetingMinutes.title')}
+ {/* Title Header with Panel Toggle */}
+
+
+
{t('meetingMinutes.title')}
+
+
+
handlePanelChange('transcription')}>
+
+
+ {t('meetingMinutes.transcription_panel')}
+
+
+ {isLargeScreen && (
+
handlePanelChange('both')}>
+
+
+ {t('meetingMinutes.both_panel')}
+
+
+ )}
+
handlePanelChange('generation')}>
+
+
+ {t('meetingMinutes.generation_panel')}
+
+
+
+
- {/* Main Content Area - Left & Right columns only */}
-
+ {/* Main Content Area - Left & Right columns */}
+
{/* Left Column - Tab Content */}
-
+
{/* Tab Headers */}
-
-
setInputMethod('transcription')}>
-
- {t('transcribe.voice_transcription')}
-
-
setInputMethod('realtime_translation')}>
-
- {t('translate.realtime_translation')}
-
-
setInputMethod('direct')}>
-
- {t('transcribe.direct_input')}
-
-
setInputMethod('file')}>
-
- {t('transcribe.file_upload')}
-
+
+
+
setInputMethod('transcription')}>
+
+
+ {t('transcribe.voice_transcription')}
+
+
+
setInputMethod('realtime_translation')}>
+
+
+ {t('translate.realtime_translation')}
+
+
+
setInputMethod('direct')}>
+
+
+ {t('transcribe.direct_input')}
+
+
+
setInputMethod('file')}>
+
+
+ {t('transcribe.file_upload')}
+
+
+
+ {/* Mobile: Show selected tab label below icons */}
+
+ {inputMethod === 'transcription' &&
+ t('transcribe.voice_transcription')}
+ {inputMethod === 'realtime_translation' &&
+ t('translate.realtime_translation')}
+ {inputMethod === 'direct' && t('transcribe.direct_input')}
+ {inputMethod === 'file' && t('transcribe.file_upload')}
+
{/* Tab Content - Self-contained components */}
-
+
- setTranscriptionRecording(state)
+ onRecordingStateChange={
+ handleTranscriptionRecordingStateChange
}
/>
- setRealtimeTranslationRecording(state)
+ onRecordingStateChange={
+ handleRealtimeTranslationRecordingStateChange
}
/>
@@ -231,6 +347,7 @@ const MeetingMinutesPage: React.FC = () => {
/>
{
{/* Right Column - Generation Panel */}
-
-
+
+
diff --git a/packages/web/src/prompts/claude.ts b/packages/web/src/prompts/claude.ts
index 1bedbdcdb..f5d10ccd1 100644
--- a/packages/web/src/prompts/claude.ts
+++ b/packages/web/src/prompts/claude.ts
@@ -37,8 +37,50 @@ import {
import { TFunction } from 'i18next';
+// eslint-disable-next-line i18nhelper/no-jp-string
+const MERMAID_SPECIAL_CHARS_WARNING = `
+## Important: Avoid Special Characters
+Mermaid cannot render certain special characters in node labels. You MUST avoid:
+
+### Characters that break syntax:
+- @ symbol
+- { } curly braces
+- ' apostrophe/single quote
+- / at the beginning of node text (e.g., [/command])
+- Japanese bullet point ・ (nakaguro)
+- Full-width symbols: # *
+
+### Reserved words and patterns:
+- The word "end" in all lowercase (use "End" or "END" instead)
+- Starting node connections with "o" or "x" (e.g., "A--oB" creates a circle edge)
+
+### Examples of problematic syntax:
+Bad examples (will not render):
+\`\`\`
+Drop[git stash drop stash@{0}]
+Process[変更・検査・ブロック]
+Node[File's content]
+Command[/newtask command]
+\`\`\`
+
+Good examples (will render):
+\`\`\`
+Drop[git stash drop - delete manually]
+Process[変更/検査/ブロック]
+Node[File content]
+Command[newtask command]
+\`\`\`
+
+### If you must use special characters:
+- Wrap text in double quotes: A["text with (parentheses)"]
+- Use HTML entity codes: #quot; for ", #35; for #
+- Replace problematic characters with alternatives: / instead of ・
+
+If technical content contains these characters, rephrase or simplify the text.`;
+
const systemContexts: { [key: string]: string } = {
'/chat': `You are an AI assistant helping users in chat.
+When explaining processes, relationships, or structures, you can use Mermaid diagrams in code blocks (e.g., \`\`\`mermaid).
Automatically detect the language of the user's request and think and answer in the same language.`,
'/summarize': `You are an AI assistant that summarizes text.
I will give you summarization instructions in the first chat, and then you should improve the summary results in subsequent chats.
@@ -562,16 +604,94 @@ Output only the selected chart type from the
list, with an exact match,
return params.customPrompt;
}
+ const diagramInstruction = `
+
+## Diagram Guidelines (Mermaid)
+When appropriate, include Mermaid diagrams to visualize:
+- Meeting flow and discussion progression (flowchart)
+- Decision trees and outcomes
+- Task assignments and responsibilities (mindmap)
+- Timeline of events or deadlines (timeline or gantt)
+- Relationships between topics or participants
+
+Use the following format for diagrams:
+\`\`\`mermaid
+[diagram code here]
+\`\`\`
+
+Only include diagrams when they genuinely help understand the content. Do not force diagrams if the content doesn't warrant visualization.`;
+
switch (params.style) {
case 'newspaper':
- return `As a professional journalist. You will receive transcribed text from reporters and craft an article while preserving as much of the original content volume as possible to deliver comprehensive information to your audience. For your audience, you must write the article in received text language.`;
+ return `As a professional journalist. You will receive transcribed text from reporters and craft an article while preserving as much of the original content volume as possible to deliver comprehensive information to your audience. For your audience, you must write the article in received text language.${diagramInstruction}`;
case 'faq':
- return `As a professional assistant, please identify the conversation topic and write an abstract summarizing the theme along with question-and-answer pairs that preserve the original information content as much as possible. For your boss, you must write in received conversation language.`;
+ return `As a professional assistant, please identify the conversation topic and write an abstract summarizing the theme along with question-and-answer pairs that preserve the original information content as much as possible. For your boss, you must write in received conversation language.${diagramInstruction}`;
+
+ case 'diagram': {
+ const diagramTypes: string[] = [];
+ const options = params.diagramOptions || [
+ 'flowchart',
+ 'mindmap',
+ 'timeline',
+ 'sequence',
+ ];
+
+ if (options.includes('flowchart')) {
+ diagramTypes.push(
+ ' - Meeting flow and key discussion points (flowchart)'
+ );
+ }
+ if (options.includes('mindmap')) {
+ diagramTypes.push(' - Decisions and action items (mindmap)');
+ }
+ if (options.includes('timeline')) {
+ diagramTypes.push(
+ ' - Timeline of events or deadlines (timeline or gantt)'
+ );
+ }
+ if (options.includes('sequence')) {
+ diagramTypes.push(
+ ' - Relationships between topics (flowchart or sequence diagram)'
+ );
+ }
+
+ const diagramInstructions =
+ diagramTypes.length > 0
+ ? `2. Create Mermaid diagrams to visualize:\n${diagramTypes.join('\n')}`
+ : '2. Create appropriate Mermaid diagrams based on the content';
+ return `As a visual documentation specialist, analyze the transcribed meeting content and create a comprehensive summary using Mermaid diagrams.
+
+## Output Guidelines
+1. Start with a brief text summary (2-3 sentences) of the meeting purpose
+${diagramInstructions}
+
+## Format Requirements
+- Use \`\`\`mermaid code blocks for all diagrams
+- Add brief explanations before each diagram
+- Ensure diagrams are clear and readable
+- Write in the same language as the input text
+${MERMAID_SPECIAL_CHARS_WARNING}`;
+ }
+
+ case 'whiteboard':
+ return `Act as a whiteboard facilitator for the meeting. Create exactly ONE Mermaid diagram that visualizes the meeting content.
+
+## Output Rules
+- Output ONLY the Mermaid code block, nothing else
+- No explanations, no summaries, no text before or after
+- Use flowchart or other diagram types as appropriate
+- Write in the same language as the input transcript
+${MERMAID_SPECIAL_CHARS_WARNING}
+
+## Output Format
+\`\`\`mermaid
+[diagram code here]
+\`\`\``;
case 'transcription':
default:
- return `As a professional translator, please correct filler words and misrecognition in received transcribed text. Please add paragraph breaks if you detect obvious topic changes, and if you find important statements related to the topic, please format them in bold style. For speakers, you must transcribe in received text language.`;
+ return `As a professional translator, please correct filler words and misrecognition in received transcribed text. Please add paragraph breaks if you detect obvious topic changes, and if you find important statements related to the topic, please format them in bold style. For speakers, you must transcribe in received text language.${diagramInstruction}`;
}
},
};
diff --git a/packages/web/src/prompts/index.ts b/packages/web/src/prompts/index.ts
index 203c90338..fb4466577 100644
--- a/packages/web/src/prompts/index.ts
+++ b/packages/web/src/prompts/index.ts
@@ -65,9 +65,24 @@ export type DiagramParams = {
diagramType?: string;
};
+export type DiagramOption =
+ | 'mindmap' // Decisions and action items
+ | 'flowchart' // Meeting flow and key discussion points
+ | 'timeline' // Timeline of events or deadlines
+ | 'sequence'; // Relationships between topics
+
export type MeetingMinutesParams = {
- style: 'transcription' | 'newspaper' | 'faq' | 'custom';
+ style:
+ | 'transcription'
+ | 'summary'
+ | 'detail'
+ | 'newspaper'
+ | 'faq'
+ | 'diagram'
+ | 'whiteboard'
+ | 'custom';
customPrompt?: string;
+ diagramOptions?: DiagramOption[];
};
export type PromptListItem = {