From b564bb2ca4d7137cba07091d2255e10104a5aa36 Mon Sep 17 00:00:00 2001 From: qyinm Date: Mon, 4 May 2026 12:33:30 +0900 Subject: [PATCH 1/5] Add transcript session screen mapping --- apps/desktop/src/utils/sessionStatus.test.ts | 66 +++++++++++++++++ apps/desktop/src/utils/sessionStatus.ts | 74 ++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 apps/desktop/src/utils/sessionStatus.test.ts create mode 100644 apps/desktop/src/utils/sessionStatus.ts diff --git a/apps/desktop/src/utils/sessionStatus.test.ts b/apps/desktop/src/utils/sessionStatus.test.ts new file mode 100644 index 0000000..eceea6e --- /dev/null +++ b/apps/desktop/src/utils/sessionStatus.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { transcriptScreenForNote } from "./sessionStatus"; +import type { AppSessionStatus } from "../types"; + +function status(state: AppSessionStatus["state"], detail: string | null = null): AppSessionStatus { + return { + state, + detail, + retryableChunkCount: state === "recoverable" ? 1 : 0, + failedChunkCount: state === "failed" || state === "recoverable" ? 1 : 0, + runningChunkCount: state === "transcribing" ? 1 : 0, + }; +} + +describe("transcriptScreenForNote", () => { + it("maps active session states to transcript status screens", () => { + expect(transcriptScreenForNote({ sessionStatus: status("recording"), hasTranscript: false })).toEqual({ + kind: "recording", + tone: "neutral", + canRetry: false, + showTranscript: false, + }); + expect(transcriptScreenForNote({ sessionStatus: status("degraded", "System audio unavailable."), hasTranscript: false })).toEqual({ + kind: "degraded", + tone: "warning", + canRetry: false, + showTranscript: false, + }); + expect(transcriptScreenForNote({ sessionStatus: status("finalizing"), hasTranscript: false })).toEqual({ + kind: "progress", + tone: "neutral", + canRetry: false, + showTranscript: false, + }); + expect(transcriptScreenForNote({ sessionStatus: status("transcribing"), hasTranscript: false })).toEqual({ + kind: "progress", + tone: "neutral", + canRetry: false, + showTranscript: false, + }); + }); + + it("maps recoverable and failed states to one retry action without chunk UI", () => { + expect(transcriptScreenForNote({ sessionStatus: status("recoverable"), hasTranscript: false })).toEqual({ + kind: "recoverable", + tone: "warning", + canRetry: true, + showTranscript: false, + }); + expect(transcriptScreenForNote({ sessionStatus: status("failed", "Capture failed."), hasTranscript: false })).toEqual({ + kind: "failed", + tone: "danger", + canRetry: true, + showTranscript: false, + }); + }); + + it("shows completed transcript content when transcript segments exist", () => { + expect(transcriptScreenForNote({ sessionStatus: status("completed"), hasTranscript: true })).toEqual({ + kind: "ready", + tone: "neutral", + canRetry: true, + showTranscript: true, + }); + }); +}); diff --git a/apps/desktop/src/utils/sessionStatus.ts b/apps/desktop/src/utils/sessionStatus.ts new file mode 100644 index 0000000..0fbee04 --- /dev/null +++ b/apps/desktop/src/utils/sessionStatus.ts @@ -0,0 +1,74 @@ +import type { AppSessionStatus } from "../types"; + +export type TranscriptScreenKind = + | "recording" + | "degraded" + | "progress" + | "recoverable" + | "failed" + | "ready" + | "empty"; + +export interface TranscriptScreenState { + kind: TranscriptScreenKind; + tone: "neutral" | "warning" | "danger"; + canRetry: boolean; + showTranscript: boolean; +} + +export function transcriptScreenForNote({ + sessionStatus, + hasTranscript, + isGeneratingTranscript = false, +}: { + sessionStatus: AppSessionStatus | null | undefined; + hasTranscript: boolean; + isGeneratingTranscript?: boolean; +}): TranscriptScreenState { + if (hasTranscript && sessionStatus?.state === "completed") { + return readyScreen(); + } + if (isGeneratingTranscript) { + return progressScreen(); + } + + switch (sessionStatus?.state) { + case "recording": + return statusScreen("recording", "neutral", false); + case "degraded": + return statusScreen("degraded", "warning", false); + case "finalizing": + case "transcribing": + return progressScreen(); + case "recoverable": + return statusScreen("recoverable", "warning", true); + case "failed": + return statusScreen("failed", "danger", true); + case "completed": + return hasTranscript ? readyScreen() : statusScreen("empty", "neutral", true); + default: + return hasTranscript ? readyScreen() : statusScreen("empty", "neutral", false); + } +} + +function progressScreen(): TranscriptScreenState { + return statusScreen("progress", "neutral", false); +} + +function readyScreen(): TranscriptScreenState { + return statusScreen("ready", "neutral", true, true); +} + +function statusScreen( + kind: TranscriptScreenKind, + tone: TranscriptScreenState["tone"], + canRetry: boolean, + showTranscript = false, +): TranscriptScreenState { + return { + kind, + tone, + canRetry, + showTranscript, + }; +} From 55dcf29a7260db8427f1899b0b7241a8244a0756 Mon Sep 17 00:00:00 2001 From: qyinm Date: Mon, 4 May 2026 12:35:02 +0900 Subject: [PATCH 2/5] Show transcript session states --- .../desktop/src/components/TranscriptView.tsx | 144 +++++++++++++++--- apps/desktop/src/i18n.tsx | 33 ++++ apps/desktop/src/styles.css | 59 +++++++ 3 files changed, 217 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/components/TranscriptView.tsx b/apps/desktop/src/components/TranscriptView.tsx index 37d71eb..084526c 100644 --- a/apps/desktop/src/components/TranscriptView.tsx +++ b/apps/desktop/src/components/TranscriptView.tsx @@ -1,8 +1,9 @@ import { useLayoutEffect, useMemo, useRef } from "react"; -import { RotateCcw, Trash2 } from "lucide-react"; +import { AlertCircle, AudioLines, CheckCircle2, RotateCcw, Trash2 } from "lucide-react"; import type { 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; @@ -28,21 +29,32 @@ 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, + hasTranscript: note.transcriptSegments.length > 0, + isGeneratingTranscript, + }); + const retryLabel = screen.kind === "recoverable" || screen.kind === "failed" || screen.kind === "empty" + ? t("transcript.retry") + : t("transcript.regenerate"); + const showHeaderRetry = screen.canRetry && screen.showTranscript; return (
- + {showHeaderRetry ? ( + + ) : null}
- {note.transcriptSegments.length === 0 && isGeneratingTranscript ? ( -
-
- ) : note.transcriptSegments.length === 0 ? ( -
{t("transcript.noTranscript")}
+ {!screen.showTranscript ? ( + ) : ( note.transcriptSegments.map((segment) => ( void; + recordingAvailable: boolean; + screen: TranscriptScreenState; +}) { + const { t } = useI18n(); + const content = transcriptStatusContent(screen, detail, t); + const Icon = screen.tone === "danger" ? AlertCircle : screen.tone === "warning" ? AlertCircle : screen.kind === "empty" ? CheckCircle2 : AudioLines; + const showInlineRetry = screen.canRetry && (screen.kind === "recoverable" || screen.kind === "failed" || screen.kind === "empty"); + const retryLabel = screen.kind === "recoverable" || screen.kind === "failed" || screen.kind === "empty" + ? t("transcript.retry") + : t("transcript.regenerate"); + + return ( +
+ +
+
{isRetrying ? t("transcript.retrying") : content.title}
+
{content.body}
+
+ {showInlineRetry ? ( + + ) : null} +
+ ); +} + +function transcriptStatusContent( + screen: TranscriptScreenState, + detail: string | null | undefined, + t: ReturnType["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": + 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: t("transcript.status.completedEmpty.title"), + body: t("transcript.status.completedEmpty.body"), + }; + default: + return { + title: t("transcript.noTranscript"), + body: t("transcript.status.recording.body"), + }; + } +} + function TranscriptSegmentEditor({ segment, speaker, diff --git a/apps/desktop/src/i18n.tsx b/apps/desktop/src/i18n.tsx index b08a4bb..876cb8e 100644 --- a/apps/desktop/src/i18n.tsx +++ b/apps/desktop/src/i18n.tsx @@ -133,6 +133,17 @@ 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.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" | "workspace.anotherRecording" | "workspace.deleteNote" | "workspace.hideSidebar" @@ -301,6 +312,17 @@ const translations: Record> = { "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.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", "workspace.anotherRecording": "Another note is recording or finalizing.", "workspace.deleteNote": "Delete note", "workspace.hideSidebar": "Hide sidebar", @@ -468,6 +490,17 @@ const translations: Record> = { "transcript.retry": "전사 재시도", "transcript.regenerating": "전사 다시 생성 중", "transcript.retrying": "전사 재시도 중", + "transcript.status.completedEmpty.body": "녹음은 로컬에 저장되어 있습니다. 전사를 다시 생성해 이 전사를 복구하세요.", + "transcript.status.completedEmpty.title": "전사가 비어 있습니다", + "transcript.status.degraded.body": "녹음은 로컬에서 계속 진행됩니다. 녹음이 끝난 뒤 저장된 오디오로 전사를 복구할 수 있습니다.", + "transcript.status.degraded.title": "실시간 전사가 불안정합니다", + "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": "전사 복구 필요", "workspace.anotherRecording": "다른 노트가 녹음 중이거나 마무리 중입니다.", "workspace.deleteNote": "노트 삭제", "workspace.hideSidebar": "사이드바 숨기기", diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 97003c9..d4aafe6 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1594,6 +1594,12 @@ button { line-height: 1.1; } +.transcript-action-button.primary { + background: var(--foreground); + border-color: var(--foreground); + color: var(--background); +} + .transcript-action-button.destructive { color: var(--destructive); border-color: color-mix(in srgb, var(--destructive) 24%, var(--border)); @@ -1624,6 +1630,59 @@ button { border-top-color: var(--foreground); } +.transcript-status { + display: grid; + grid-template-columns: 32px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + margin-top: 14px; + padding: 14px 0; + border-bottom: 1px solid var(--border); +} + +.transcript-status-icon { + display: inline-grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 999px; + background: var(--surface-400); + color: var(--muted-foreground); +} + +.transcript-status-warning .transcript-status-icon { + background: color-mix(in srgb, var(--evidence) 13%, transparent); + color: var(--evidence); +} + +.transcript-status-danger .transcript-status-icon { + background: color-mix(in srgb, var(--destructive) 10%, transparent); + color: var(--destructive); +} + +.transcript-status-copy { + min-width: 0; +} + +.transcript-status-title { + color: var(--foreground); + font-size: 13px; + font-weight: 650; + line-height: 1.35; +} + +.transcript-status-body { + max-width: 560px; + margin-top: 3px; + color: var(--muted-foreground); + font-size: 12px; + line-height: 1.5; +} + +.transcript-status-action { + align-self: center; +} + .speaker-labeling-banner { display: flex; align-items: center; From d9b5298146b79c03e8a8ec3b47d0ceaf99417364 Mon Sep 17 00:00:00 2001 From: qyinm Date: Mon, 4 May 2026 12:37:05 +0900 Subject: [PATCH 3/5] Show compact session indicators --- .../src/components/NoteActivityIndicator.tsx | 21 ++++++++++ apps/desktop/src/components/Sidebar.tsx | 2 +- apps/desktop/src/i18n.tsx | 11 +++++- apps/desktop/src/styles.css | 16 ++++++++ apps/desktop/src/types.ts | 2 +- .../desktop/src/utils/captureActivity.test.ts | 10 ++++- apps/desktop/src/utils/captureActivity.ts | 38 +++++++++++++------ 7 files changed, 84 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/components/NoteActivityIndicator.tsx b/apps/desktop/src/components/NoteActivityIndicator.tsx index f877b58..cdfbc63 100644 --- a/apps/desktop/src/components/NoteActivityIndicator.tsx +++ b/apps/desktop/src/components/NoteActivityIndicator.tsx @@ -15,6 +15,13 @@ export function NoteActivityIndicator({ activity }: { activity: NoteActivity }) ); } + if (activity === "degraded") { + return ( + + + ); + } if (activity === "failed") { return ( @@ -22,6 +29,20 @@ export function NoteActivityIndicator({ activity }: { activity: NoteActivity }) ); } + if (activity === "recoverable") { + return ( + + + ); + } + if (activity === "transcribing") { + return ( + + + ); + } return ( - + )) )} diff --git a/apps/desktop/src/i18n.tsx b/apps/desktop/src/i18n.tsx index 876cb8e..2fb47b5 100644 --- a/apps/desktop/src/i18n.tsx +++ b/apps/desktop/src/i18n.tsx @@ -176,8 +176,11 @@ type I18nKey = | "workspace.transcript" | "workspace.uploadAsset" | "activity.captureFailed" + | "activity.degraded" | "activity.finalizing" - | "activity.recording"; + | "activity.recoverable" + | "activity.recording" + | "activity.transcribing"; const translations: Record> = { en: { @@ -355,8 +358,11 @@ const translations: Record> = { "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": "로컬 회의 메모리", @@ -533,8 +539,11 @@ const translations: Record> = { "workspace.transcript": "전사", "workspace.uploadAsset": "녹음 또는 전사 업로드", "activity.captureFailed": "캡처 실패", + "activity.degraded": "녹음 불안정", "activity.finalizing": "마무리 중", + "activity.recoverable": "복구 필요", "activity.recording": "녹음 중", + "activity.transcribing": "전사 중", }, }; diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index d4aafe6..238d2ec 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -433,6 +433,11 @@ button { box-shadow: 0 0 0 3px color-mix(in srgb, var(--error-red) 18%, transparent); } +.on-air-dot.degraded { + background: var(--evidence); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--evidence) 18%, transparent); +} + .progress-ring { width: 14px; height: 14px; @@ -442,6 +447,10 @@ button { animation: progress-ring-spin 780ms linear infinite; } +.progress-ring.transcribing { + border-top-color: var(--agent-blue); +} + .note-activity-failed { width: 15px; height: 15px; @@ -449,6 +458,13 @@ button { stroke-width: 2.2; } +.note-activity-recoverable { + width: 15px; + height: 15px; + color: var(--evidence); + stroke-width: 2.2; +} + .note-context-menu { position: fixed; z-index: 40; diff --git a/apps/desktop/src/types.ts b/apps/desktop/src/types.ts index 2bec12a..519da8e 100644 --- a/apps/desktop/src/types.ts +++ b/apps/desktop/src/types.ts @@ -5,7 +5,7 @@ export type SidebarMode = "notes" | "templates" | "settings"; export type SaveState = "idle" | "saving" | "saved" | "failed"; export type CaptureState = "idle" | "starting" | "capturing" | "degraded" | "stopping" | "finalizing" | "completed" | "failed"; export type EditorCommand = "paragraph" | "heading1" | "heading2" | "bullet" | "checklist" | "divider"; -export type NoteActivity = "recording" | "finalizing" | "failed" | null; +export type NoteActivity = "recording" | "degraded" | "finalizing" | "transcribing" | "failed" | "recoverable" | null; export type TranscriptSource = "microphone" | "system"; export type ListenerSessionState = "starting" | "recording" | "stopping" | "recovering" | "failed" | "completed"; export type AppSessionState = "recording" | "degraded" | "finalizing" | "transcribing" | "failed" | "completed" | "recoverable"; diff --git a/apps/desktop/src/utils/captureActivity.test.ts b/apps/desktop/src/utils/captureActivity.test.ts index 851fd04..f012767 100644 --- a/apps/desktop/src/utils/captureActivity.test.ts +++ b/apps/desktop/src/utils/captureActivity.test.ts @@ -13,7 +13,7 @@ import type { CaptureState } from "../types"; describe("activityForNote", () => { it("shows recording only on the active processing note while capture is live", () => { expect(activityForNote("note-a", "note-a", "capturing")).toBe("recording"); - expect(activityForNote("note-a", "note-a", "degraded")).toBe("recording"); + expect(activityForNote("note-a", "note-a", "degraded")).toBe("degraded"); expect(activityForNote("note-b", "note-a", "capturing")).toBeNull(); expect(activityForNote("note-a", null, "capturing")).toBeNull(); }); @@ -29,6 +29,14 @@ describe("activityForNote", () => { expect(activityForNote("note-a", "note-a", "idle")).toBeNull(); expect(activityForNote("note-a", "note-a", "completed")).toBeNull(); }); + + it("falls back to compact persisted session status for inactive notes", () => { + expect(activityForNote("note-a", null, "idle", { state: "recording", detail: null, retryableChunkCount: 0, failedChunkCount: 0, runningChunkCount: 0 })).toBe("recording"); + expect(activityForNote("note-a", null, "idle", { state: "degraded", detail: "System audio unavailable.", retryableChunkCount: 0, failedChunkCount: 0, runningChunkCount: 0 })).toBe("degraded"); + expect(activityForNote("note-a", null, "idle", { state: "transcribing", detail: null, retryableChunkCount: 0, failedChunkCount: 0, runningChunkCount: 1 })).toBe("transcribing"); + expect(activityForNote("note-a", null, "idle", { state: "recoverable", detail: null, retryableChunkCount: 1, failedChunkCount: 1, runningChunkCount: 0 })).toBe("recoverable"); + expect(activityForNote("note-a", null, "idle", { state: "completed", detail: null, retryableChunkCount: 0, failedChunkCount: 0, runningChunkCount: 0 })).toBeNull(); + }); }); describe("capture state helpers", () => { diff --git a/apps/desktop/src/utils/captureActivity.ts b/apps/desktop/src/utils/captureActivity.ts index 4d470a9..4272fbf 100644 --- a/apps/desktop/src/utils/captureActivity.ts +++ b/apps/desktop/src/utils/captureActivity.ts @@ -1,4 +1,4 @@ -import type { CaptureState, NoteActivity } from "../types"; +import type { AppSessionStatus, CaptureState, NoteActivity } from "../types"; export function isProcessingCaptureState(captureState: CaptureState) { return captureState === "starting" @@ -62,18 +62,32 @@ export function activityForNote( noteId: string, activeProcessingNoteId: string | null, captureState: CaptureState, + sessionStatus?: AppSessionStatus | null, ): NoteActivity { - if (!activeProcessingNoteId || noteId !== activeProcessingNoteId) { - return null; + if (activeProcessingNoteId && noteId === activeProcessingNoteId) { + if (captureState === "capturing") { + return "recording"; + } + if (captureState === "degraded") { + return "degraded"; + } + if (captureState === "failed") { + return "failed"; + } + if (captureState === "starting" || captureState === "stopping" || captureState === "finalizing") { + return "finalizing"; + } } - if (captureState === "capturing" || captureState === "degraded") { - return "recording"; + switch (sessionStatus?.state) { + case "recording": + case "degraded": + case "transcribing": + case "failed": + case "recoverable": + return sessionStatus.state; + case "finalizing": + return "finalizing"; + default: + return null; } - if (captureState === "failed") { - return "failed"; - } - if (captureState === "starting" || captureState === "stopping" || captureState === "finalizing") { - return "finalizing"; - } - return null; } From c0b685c25d74dc9d89e960ecd6d91a8bd1ab22e9 Mon Sep 17 00:00:00 2001 From: qyinm Date: Mon, 4 May 2026 12:37:43 +0900 Subject: [PATCH 4/5] Distinguish transcription progress copy --- apps/desktop/src/components/TranscriptView.tsx | 12 +++++++++++- apps/desktop/src/i18n.tsx | 6 ++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/components/TranscriptView.tsx b/apps/desktop/src/components/TranscriptView.tsx index 084526c..25fc0ff 100644 --- a/apps/desktop/src/components/TranscriptView.tsx +++ b/apps/desktop/src/components/TranscriptView.tsx @@ -74,6 +74,7 @@ export function TranscriptView({ onRetryTranscript={onRetryTranscript} recordingAvailable={Boolean(note.recordingURL)} screen={screen} + sessionState={note.sessionStatus?.state} /> ) : ( note.transcriptSegments.map((segment) => ( @@ -95,15 +96,17 @@ function TranscriptStatusPanel({ onRetryTranscript, recordingAvailable, screen, + sessionState, }: { detail: string | null | undefined; isRetrying: boolean; onRetryTranscript: () => void; recordingAvailable: boolean; screen: TranscriptScreenState; + sessionState: string | undefined; }) { const { t } = useI18n(); - const content = transcriptStatusContent(screen, detail, t); + const content = transcriptStatusContent(screen, detail, sessionState, t); const Icon = screen.tone === "danger" ? AlertCircle : screen.tone === "warning" ? AlertCircle : screen.kind === "empty" ? CheckCircle2 : AudioLines; const showInlineRetry = screen.canRetry && (screen.kind === "recoverable" || screen.kind === "failed" || screen.kind === "empty"); const retryLabel = screen.kind === "recoverable" || screen.kind === "failed" || screen.kind === "empty" @@ -141,6 +144,7 @@ function TranscriptStatusPanel({ function transcriptStatusContent( screen: TranscriptScreenState, detail: string | null | undefined, + sessionState: string | undefined, t: ReturnType["t"], ) { switch (screen.kind) { @@ -155,6 +159,12 @@ function transcriptStatusContent( 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"), diff --git a/apps/desktop/src/i18n.tsx b/apps/desktop/src/i18n.tsx index 2fb47b5..2fa428c 100644 --- a/apps/desktop/src/i18n.tsx +++ b/apps/desktop/src/i18n.tsx @@ -144,6 +144,8 @@ type I18nKey = | "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" @@ -326,6 +328,8 @@ const translations: Record> = { "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", @@ -507,6 +511,8 @@ const translations: Record> = { "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": "사이드바 숨기기", From ccd174b2ff061ce2edae730fa4c6f59dd4cedb1c Mon Sep 17 00:00:00 2001 From: qyinm Date: Mon, 4 May 2026 13:19:17 +0900 Subject: [PATCH 5/5] Address transcript recovery review feedback --- .../desktop/src/components/TranscriptView.tsx | 25 ++++++++----------- apps/desktop/src/i18n.tsx | 3 +++ apps/desktop/src/styles.css | 2 +- apps/desktop/src/utils/sessionStatus.test.ts | 24 ++++++++++++++++++ apps/desktop/src/utils/sessionStatus.ts | 8 ++++-- 5 files changed, 45 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/components/TranscriptView.tsx b/apps/desktop/src/components/TranscriptView.tsx index 25fc0ff..5f31fad 100644 --- a/apps/desktop/src/components/TranscriptView.tsx +++ b/apps/desktop/src/components/TranscriptView.tsx @@ -1,6 +1,6 @@ import { useLayoutEffect, useMemo, useRef } from "react"; -import { AlertCircle, AudioLines, CheckCircle2, 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"; @@ -31,12 +31,11 @@ export function TranscriptView({ }, [note.speakerLabels]); const screen = transcriptScreenForNote({ sessionStatus: note.sessionStatus, + hasRecording: Boolean(note.recordingURL), hasTranscript: note.transcriptSegments.length > 0, isGeneratingTranscript, }); - const retryLabel = screen.kind === "recoverable" || screen.kind === "failed" || screen.kind === "empty" - ? t("transcript.retry") - : t("transcript.regenerate"); + const retryLabel = screen.action === "retry" ? t("transcript.retry") : t("transcript.regenerate"); const showHeaderRetry = screen.canRetry && screen.showTranscript; return ( @@ -103,15 +102,13 @@ function TranscriptStatusPanel({ onRetryTranscript: () => void; recordingAvailable: boolean; screen: TranscriptScreenState; - sessionState: string | undefined; + sessionState: AppSessionStatus["state"] | undefined; }) { const { t } = useI18n(); const content = transcriptStatusContent(screen, detail, sessionState, t); - const Icon = screen.tone === "danger" ? AlertCircle : screen.tone === "warning" ? AlertCircle : screen.kind === "empty" ? CheckCircle2 : AudioLines; - const showInlineRetry = screen.canRetry && (screen.kind === "recoverable" || screen.kind === "failed" || screen.kind === "empty"); - const retryLabel = screen.kind === "recoverable" || screen.kind === "failed" || screen.kind === "empty" - ? t("transcript.retry") - : t("transcript.regenerate"); + 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 (
@@ -144,7 +141,7 @@ function TranscriptStatusPanel({ function transcriptStatusContent( screen: TranscriptScreenState, detail: string | null | undefined, - sessionState: string | undefined, + sessionState: AppSessionStatus["state"] | undefined, t: ReturnType["t"], ) { switch (screen.kind) { @@ -181,8 +178,8 @@ function transcriptStatusContent( }; case "empty": return { - title: t("transcript.status.completedEmpty.title"), - body: t("transcript.status.completedEmpty.body"), + title: screen.canRetry ? t("transcript.status.completedEmpty.title") : t("transcript.noTranscript"), + body: screen.canRetry ? t("transcript.status.completedEmpty.body") : t("transcript.status.empty.body"), }; default: return { diff --git a/apps/desktop/src/i18n.tsx b/apps/desktop/src/i18n.tsx index 2fa428c..df61f88 100644 --- a/apps/desktop/src/i18n.tsx +++ b/apps/desktop/src/i18n.tsx @@ -137,6 +137,7 @@ type I18nKey = | "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" @@ -321,6 +322,7 @@ const translations: Record> = { "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", @@ -504,6 +506,7 @@ const translations: Record> = { "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": "전사 마무리 중", diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 238d2ec..5dffa72 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -448,7 +448,7 @@ button { } .progress-ring.transcribing { - border-top-color: var(--agent-blue); + border-top-color: var(--agent); } .note-activity-failed { diff --git a/apps/desktop/src/utils/sessionStatus.test.ts b/apps/desktop/src/utils/sessionStatus.test.ts index eceea6e..8e1170b 100644 --- a/apps/desktop/src/utils/sessionStatus.test.ts +++ b/apps/desktop/src/utils/sessionStatus.test.ts @@ -19,24 +19,28 @@ describe("transcriptScreenForNote", () => { tone: "neutral", canRetry: false, showTranscript: false, + action: null, }); expect(transcriptScreenForNote({ sessionStatus: status("degraded", "System audio unavailable."), hasTranscript: false })).toEqual({ kind: "degraded", tone: "warning", canRetry: false, showTranscript: false, + action: null, }); expect(transcriptScreenForNote({ sessionStatus: status("finalizing"), hasTranscript: false })).toEqual({ kind: "progress", tone: "neutral", canRetry: false, showTranscript: false, + action: null, }); expect(transcriptScreenForNote({ sessionStatus: status("transcribing"), hasTranscript: false })).toEqual({ kind: "progress", tone: "neutral", canRetry: false, showTranscript: false, + action: null, }); }); @@ -46,12 +50,14 @@ describe("transcriptScreenForNote", () => { tone: "warning", canRetry: true, showTranscript: false, + action: "retry", }); expect(transcriptScreenForNote({ sessionStatus: status("failed", "Capture failed."), hasTranscript: false })).toEqual({ kind: "failed", tone: "danger", canRetry: true, showTranscript: false, + action: "retry", }); }); @@ -61,6 +67,24 @@ describe("transcriptScreenForNote", () => { tone: "neutral", canRetry: true, showTranscript: true, + action: "regenerate", + }); + }); + + it("offers recovery for empty notes only when a recording exists", () => { + expect(transcriptScreenForNote({ sessionStatus: null, hasRecording: true, hasTranscript: false })).toEqual({ + kind: "empty", + tone: "neutral", + canRetry: true, + showTranscript: false, + action: "retry", + }); + expect(transcriptScreenForNote({ sessionStatus: null, hasRecording: false, hasTranscript: false })).toEqual({ + kind: "empty", + tone: "neutral", + canRetry: false, + showTranscript: false, + action: null, }); }); }); diff --git a/apps/desktop/src/utils/sessionStatus.ts b/apps/desktop/src/utils/sessionStatus.ts index 0fbee04..83d9106 100644 --- a/apps/desktop/src/utils/sessionStatus.ts +++ b/apps/desktop/src/utils/sessionStatus.ts @@ -14,14 +14,17 @@ export interface TranscriptScreenState { tone: "neutral" | "warning" | "danger"; canRetry: boolean; showTranscript: boolean; + action: "retry" | "regenerate" | null; } export function transcriptScreenForNote({ sessionStatus, + hasRecording = false, hasTranscript, isGeneratingTranscript = false, }: { sessionStatus: AppSessionStatus | null | undefined; + hasRecording?: boolean; hasTranscript: boolean; isGeneratingTranscript?: boolean; }): TranscriptScreenState { @@ -45,9 +48,9 @@ export function transcriptScreenForNote({ case "failed": return statusScreen("failed", "danger", true); case "completed": - return hasTranscript ? readyScreen() : statusScreen("empty", "neutral", true); + return hasTranscript ? readyScreen() : statusScreen("empty", "neutral", hasRecording); default: - return hasTranscript ? readyScreen() : statusScreen("empty", "neutral", false); + return hasTranscript ? readyScreen() : statusScreen("empty", "neutral", hasRecording); } } @@ -70,5 +73,6 @@ function statusScreen( tone, canRetry, showTranscript, + action: canRetry ? (showTranscript ? "regenerate" : "retry") : null, }; }