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/Sidebar.tsx b/apps/desktop/src/components/Sidebar.tsx
index 2873235..3c2b4aa 100644
--- a/apps/desktop/src/components/Sidebar.tsx
+++ b/apps/desktop/src/components/Sidebar.tsx
@@ -181,7 +181,7 @@ export function Sidebar({
{note.title}
{note.startedAt ? formatDate(note.startedAt, appLanguage) : t("sidebar.noRecording")}
-
+
))
)}
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 ? (
-
-
- {t("transcript.regenerating")}
-
- ) : 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 (
+
+
+ {screen.kind === "progress" || isRetrying ? (
+
+ ) : (
+
+ )}
+
+
+
{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,
+ };
+}