Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/desktop/src/components/NoteActivityIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,34 @@ export function NoteActivityIndicator({ activity }: { activity: NoteActivity })
</span>
);
}
if (activity === "degraded") {
return (
<span className="note-activity" title={t("activity.degraded")} aria-label={t("activity.degraded")}>
<span className="on-air-dot degraded" aria-hidden="true" />
</span>
);
}
if (activity === "failed") {
return (
<span className="note-activity" title={t("activity.captureFailed")} aria-label={t("activity.captureFailed")}>
<CircleAlert className="note-activity-failed" aria-hidden="true" />
</span>
);
}
if (activity === "recoverable") {
return (
<span className="note-activity" title={t("activity.recoverable")} aria-label={t("activity.recoverable")}>
<CircleAlert className="note-activity-recoverable" aria-hidden="true" />
</span>
);
}
if (activity === "transcribing") {
return (
<span className="note-activity" title={t("activity.transcribing")} aria-label={t("activity.transcribing")}>
<span className="progress-ring transcribing" aria-hidden="true" />
</span>
);
}
return (
<span className="note-activity" title={t("activity.finalizing")} aria-label={t("activity.finalizing")}>
<span className="progress-ring" aria-hidden="true" />
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ export function Sidebar({
<span className="note-row-title">{note.title}</span>
<span className="note-row-meta">{note.startedAt ? formatDate(note.startedAt, appLanguage) : t("sidebar.noRecording")}</span>
</span>
<NoteActivityIndicator activity={activityForNote(note.id, activeProcessingNoteId, captureState)} />
<NoteActivityIndicator activity={activityForNote(note.id, activeProcessingNoteId, captureState, note.sessionStatus)} />
</button>
))
)}
Expand Down
153 changes: 133 additions & 20 deletions apps/desktop/src/components/TranscriptView.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useLayoutEffect, useMemo, useRef } from "react";
import { RotateCcw, Trash2 } from "lucide-react";
import type { NoteDetail, SpeakerLabel, TranscriptSegment } from "../types";
import { AudioLines, CheckCircle2, CircleAlert, RotateCcw, Trash2 } from "lucide-react";
import type { AppSessionStatus, NoteDetail, SpeakerLabel, TranscriptSegment } from "../types";
import { useI18n } from "../i18n";
import { formatRange } from "../utils/format";
import { transcriptScreenForNote, type TranscriptScreenState } from "../utils/sessionStatus";

interface TranscriptViewProps {
note: NoteDetail;
Expand All @@ -28,21 +29,31 @@ export function TranscriptView({
const labels = Array.isArray(note.speakerLabels) ? note.speakerLabels : [];
return new Map(labels.map((speaker) => [speaker.speakerId, speaker]));
}, [note.speakerLabels]);
const screen = transcriptScreenForNote({
sessionStatus: note.sessionStatus,
hasRecording: Boolean(note.recordingURL),
hasTranscript: note.transcriptSegments.length > 0,
isGeneratingTranscript,
});
const retryLabel = screen.action === "retry" ? t("transcript.retry") : t("transcript.regenerate");
const showHeaderRetry = screen.canRetry && screen.showTranscript;

return (
<div className="transcript-view">
<div className="transcript-actions">
<button
className="transcript-action-button"
type="button"
title={isRetrying ? t("transcript.regenerating") : t("transcript.regenerate")}
aria-label={isRetrying ? t("transcript.regenerating") : t("transcript.regenerate")}
disabled={isRetrying || !note.recordingURL}
onClick={onRetryTranscript}
>
{isRetrying ? <span className="button-progress-ring" aria-hidden="true" /> : <RotateCcw className="icon-svg" aria-hidden="true" />}
<span>{isRetrying ? t("transcript.regenerating") : t("transcript.regenerate")}</span>
</button>
{showHeaderRetry ? (
<button
className={`transcript-action-button${screen.kind === "recoverable" || screen.kind === "failed" ? " primary" : ""}`}
type="button"
title={isRetrying ? t("transcript.regenerating") : retryLabel}
aria-label={isRetrying ? t("transcript.regenerating") : retryLabel}
disabled={isRetrying || !note.recordingURL}
onClick={onRetryTranscript}
>
{isRetrying ? <span className="button-progress-ring" aria-hidden="true" /> : <RotateCcw className="icon-svg" aria-hidden="true" />}
<span>{isRetrying ? t("transcript.regenerating") : retryLabel}</span>
</button>
) : null}
<button
className="transcript-action-button destructive"
type="button"
Expand All @@ -55,13 +66,15 @@ export function TranscriptView({
<span>{t("transcript.deleteRecording")}</span>
</button>
</div>
{note.transcriptSegments.length === 0 && isGeneratingTranscript ? (
<div className="transcript-empty transcript-empty-processing">
<span className="button-progress-ring transcript-empty-spinner" aria-hidden="true" />
<span>{t("transcript.regenerating")}</span>
</div>
) : note.transcriptSegments.length === 0 ? (
<div className="transcript-empty">{t("transcript.noTranscript")}</div>
{!screen.showTranscript ? (
<TranscriptStatusPanel
detail={note.sessionStatus?.detail}
isRetrying={isRetrying}
onRetryTranscript={onRetryTranscript}
recordingAvailable={Boolean(note.recordingURL)}
screen={screen}
sessionState={note.sessionStatus?.state}
/>
) : (
note.transcriptSegments.map((segment) => (
<TranscriptSegmentEditor
Expand All @@ -76,6 +89,106 @@ export function TranscriptView({
);
}

function TranscriptStatusPanel({
detail,
isRetrying,
onRetryTranscript,
recordingAvailable,
screen,
sessionState,
}: {
detail: string | null | undefined;
isRetrying: boolean;
onRetryTranscript: () => void;
recordingAvailable: boolean;
screen: TranscriptScreenState;
sessionState: AppSessionStatus["state"] | undefined;
}) {
const { t } = useI18n();
const content = transcriptStatusContent(screen, detail, sessionState, t);
const Icon = screen.tone === "danger" || screen.tone === "warning" ? CircleAlert : screen.kind === "empty" ? CheckCircle2 : AudioLines;
const showInlineRetry = Boolean(screen.action) && !screen.showTranscript;
const retryLabel = screen.action === "retry" ? t("transcript.retry") : t("transcript.regenerate");

return (
<div className={`transcript-status transcript-status-${screen.tone}`}>
<div className="transcript-status-icon" aria-hidden="true">
{screen.kind === "progress" || isRetrying ? (
<span className="button-progress-ring transcript-empty-spinner" />
) : (
<Icon className="icon-svg" />
)}
</div>
<div className="transcript-status-copy">
<div className="transcript-status-title">{isRetrying ? t("transcript.retrying") : content.title}</div>
<div className="transcript-status-body">{content.body}</div>
</div>
{showInlineRetry ? (
<button
className="transcript-action-button primary transcript-status-action"
type="button"
disabled={isRetrying || !recordingAvailable}
onClick={onRetryTranscript}
>
{isRetrying ? <span className="button-progress-ring" aria-hidden="true" /> : <RotateCcw className="icon-svg" aria-hidden="true" />}
<span>{isRetrying ? t("transcript.retrying") : retryLabel}</span>
</button>
) : null}
</div>
);
}

function transcriptStatusContent(
screen: TranscriptScreenState,
detail: string | null | undefined,
sessionState: AppSessionStatus["state"] | undefined,
t: ReturnType<typeof useI18n>["t"],
) {
switch (screen.kind) {
case "recording":
return {
title: t("transcript.status.recording.title"),
body: t("transcript.status.recording.body"),
};
case "degraded":
return {
title: t("transcript.status.degraded.title"),
body: detail || t("transcript.status.degraded.body"),
};
case "progress":
if (sessionState === "transcribing") {
return {
title: t("transcript.status.transcribing.title"),
body: t("transcript.status.transcribing.body"),
};
}
return {
title: t("transcript.status.finalizing.title"),
body: t("transcript.status.finalizing.body"),
};
case "recoverable":
return {
title: t("transcript.status.recoverable.title"),
body: detail || t("transcript.status.recoverable.body"),
};
case "failed":
return {
title: t("transcript.status.failed.title"),
body: detail || t("error.somethingFailed"),
};
case "empty":
return {
title: screen.canRetry ? t("transcript.status.completedEmpty.title") : t("transcript.noTranscript"),
body: screen.canRetry ? t("transcript.status.completedEmpty.body") : t("transcript.status.empty.body"),
};
Comment thread
qyinm marked this conversation as resolved.
default:
return {
title: t("transcript.noTranscript"),
body: t("transcript.status.recording.body"),
};
}
}

function TranscriptSegmentEditor({
segment,
speaker,
Expand Down
53 changes: 52 additions & 1 deletion apps/desktop/src/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,20 @@ type I18nKey =
| "transcript.retry"
| "transcript.regenerating"
| "transcript.retrying"
| "transcript.status.completedEmpty.body"
| "transcript.status.completedEmpty.title"
| "transcript.status.degraded.body"
| "transcript.status.degraded.title"
| "transcript.status.empty.body"
| "transcript.status.failed.title"
| "transcript.status.finalizing.body"
| "transcript.status.finalizing.title"
| "transcript.status.recording.body"
| "transcript.status.recording.title"
| "transcript.status.recoverable.body"
| "transcript.status.recoverable.title"
| "transcript.status.transcribing.body"
| "transcript.status.transcribing.title"
| "workspace.anotherRecording"
| "workspace.deleteNote"
| "workspace.hideSidebar"
Expand Down Expand Up @@ -165,8 +179,11 @@ type I18nKey =
| "workspace.transcript"
| "workspace.uploadAsset"
| "activity.captureFailed"
| "activity.degraded"
| "activity.finalizing"
| "activity.recording";
| "activity.recoverable"
| "activity.recording"
| "activity.transcribing";

const translations: Record<AppLanguage, Record<I18nKey, string>> = {
en: {
Expand Down Expand Up @@ -301,6 +318,20 @@ const translations: Record<AppLanguage, Record<I18nKey, string>> = {
"transcript.retry": "Retry transcript",
"transcript.regenerating": "Regenerating transcription",
"transcript.retrying": "Retrying transcript",
"transcript.status.completedEmpty.body": "The recording is saved locally. Retry transcription to rebuild this transcript.",
"transcript.status.completedEmpty.title": "Transcript is empty",
"transcript.status.degraded.body": "Recording continues locally. The transcript can be recovered from the saved audio after recording stops.",
"transcript.status.degraded.title": "Live transcription is degraded",
"transcript.status.empty.body": "Start recording to capture audio and generate a transcript.",
"transcript.status.failed.title": "Transcription failed",
"transcript.status.finalizing.body": "MirrorNote is writing the final local transcript.",
"transcript.status.finalizing.title": "Finalizing transcript",
"transcript.status.recording.body": "Transcript segments will appear here as they are saved.",
"transcript.status.recording.title": "Recording locally",
"transcript.status.recoverable.body": "The recording is saved locally. Retry transcription to recover the missing transcript.",
"transcript.status.recoverable.title": "Transcript needs recovery",
"transcript.status.transcribing.body": "MirrorNote is transcribing saved local audio.",
"transcript.status.transcribing.title": "Transcribing audio",
"workspace.anotherRecording": "Another note is recording or finalizing.",
"workspace.deleteNote": "Delete note",
"workspace.hideSidebar": "Hide sidebar",
Expand Down Expand Up @@ -333,8 +364,11 @@ const translations: Record<AppLanguage, Record<I18nKey, string>> = {
"workspace.transcript": "Transcript",
"workspace.uploadAsset": "Upload recording or transcript",
"activity.captureFailed": "Capture failed",
"activity.degraded": "Recording degraded",
"activity.finalizing": "Finalizing",
"activity.recoverable": "Needs recovery",
"activity.recording": "Recording",
"activity.transcribing": "Transcribing",
},
ko: {
"app.subtitle": "로컬 회의 메모리",
Expand Down Expand Up @@ -468,6 +502,20 @@ const translations: Record<AppLanguage, Record<I18nKey, string>> = {
"transcript.retry": "전사 재시도",
"transcript.regenerating": "전사 다시 생성 중",
"transcript.retrying": "전사 재시도 중",
"transcript.status.completedEmpty.body": "녹음은 로컬에 저장되어 있습니다. 전사를 다시 생성해 이 전사를 복구하세요.",
"transcript.status.completedEmpty.title": "전사가 비어 있습니다",
"transcript.status.degraded.body": "녹음은 로컬에서 계속 진행됩니다. 녹음이 끝난 뒤 저장된 오디오로 전사를 복구할 수 있습니다.",
"transcript.status.degraded.title": "실시간 전사가 불안정합니다",
"transcript.status.empty.body": "녹음을 시작하면 오디오를 캡처하고 전사를 생성합니다.",
"transcript.status.failed.title": "전사 실패",
"transcript.status.finalizing.body": "MirrorNote가 최종 로컬 전사를 쓰는 중입니다.",
"transcript.status.finalizing.title": "전사 마무리 중",
"transcript.status.recording.body": "저장되는 대로 전사 세그먼트가 여기에 표시됩니다.",
"transcript.status.recording.title": "로컬 녹음 중",
"transcript.status.recoverable.body": "녹음은 로컬에 저장되어 있습니다. 전사를 재시도해 누락된 전사를 복구하세요.",
"transcript.status.recoverable.title": "전사 복구 필요",
"transcript.status.transcribing.body": "MirrorNote가 저장된 로컬 오디오를 전사하는 중입니다.",
"transcript.status.transcribing.title": "오디오 전사 중",
"workspace.anotherRecording": "다른 노트가 녹음 중이거나 마무리 중입니다.",
"workspace.deleteNote": "노트 삭제",
"workspace.hideSidebar": "사이드바 숨기기",
Expand Down Expand Up @@ -500,8 +548,11 @@ const translations: Record<AppLanguage, Record<I18nKey, string>> = {
"workspace.transcript": "전사",
"workspace.uploadAsset": "녹음 또는 전사 업로드",
"activity.captureFailed": "캡처 실패",
"activity.degraded": "녹음 불안정",
"activity.finalizing": "마무리 중",
"activity.recoverable": "복구 필요",
"activity.recording": "녹음 중",
"activity.transcribing": "전사 중",
},
};

Expand Down
Loading