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/components/TranscriptView.tsx b/apps/desktop/src/components/TranscriptView.tsx index 37d71eb..5f31fad 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 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; @@ -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 (
- + {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; + 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 ( +
+ +
+
{isRetrying ? t("transcript.retrying") : content.title}
+
{content.body}
+
+ {showInlineRetry ? ( + + ) : null} +
+ ); +} + +function transcriptStatusContent( + screen: TranscriptScreenState, + detail: string | null | undefined, + sessionState: AppSessionStatus["state"] | 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": + 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"), + }; + 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..df61f88 100644 --- a/apps/desktop/src/i18n.tsx +++ b/apps/desktop/src/i18n.tsx @@ -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" @@ -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> = { en: { @@ -301,6 +318,20 @@ 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.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", @@ -333,8 +364,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": "로컬 회의 메모리", @@ -468,6 +502,20 @@ 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.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": "사이드바 숨기기", @@ -500,8 +548,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 97003c9..5dffa72 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); +} + .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; @@ -1594,6 +1610,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 +1646,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; 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; } diff --git a/apps/desktop/src/utils/sessionStatus.test.ts b/apps/desktop/src/utils/sessionStatus.test.ts new file mode 100644 index 0000000..8e1170b --- /dev/null +++ b/apps/desktop/src/utils/sessionStatus.test.ts @@ -0,0 +1,90 @@ +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, + 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, + }); + }); + + 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, + action: "retry", + }); + expect(transcriptScreenForNote({ sessionStatus: status("failed", "Capture failed."), hasTranscript: false })).toEqual({ + kind: "failed", + tone: "danger", + canRetry: true, + showTranscript: false, + action: "retry", + }); + }); + + 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, + 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 new file mode 100644 index 0000000..83d9106 --- /dev/null +++ b/apps/desktop/src/utils/sessionStatus.ts @@ -0,0 +1,78 @@ +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; + action: "retry" | "regenerate" | null; +} + +export function transcriptScreenForNote({ + sessionStatus, + hasRecording = false, + hasTranscript, + isGeneratingTranscript = false, +}: { + sessionStatus: AppSessionStatus | null | undefined; + hasRecording?: boolean; + 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", hasRecording); + default: + return hasTranscript ? readyScreen() : statusScreen("empty", "neutral", hasRecording); + } +} + +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, + action: canRetry ? (showTranscript ? "regenerate" : "retry") : null, + }; +}