diff --git a/:memory:.ses b/:memory:.ses
new file mode 100644
index 00000000..e22cf5bb
--- /dev/null
+++ b/:memory:.ses
@@ -0,0 +1,2 @@
+1788271637990
+d8d64392-e276-e11d-730f-432cfd53e5f2
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2bf42b70..29210c51 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -58,6 +58,8 @@ the frozen-backend fallback mirror it for their toolchains.
### Fixed
+- The Transcriptions view no longer goes blank on a transcript whose segments have no timings — an ASR backend that returns text without timestamps is rendered as text instead of crashing the page (#1798)
+
- The generation compute-time budget is now a Settings control (Performance & Device) instead of an env-var-only setting the timeout error recommended with no UI path — the error copy points there too, and long CPU/MPS renders get an upfront heads-up before they start (#1787)
- Windows: the backend can now start when the install path contains non-English characters (e.g. a CJK username) on a non-UTF-8 system code page — a new or broken Python environment now builds at an ASCII-safe path automatically (a healthy existing one is never relocated), and a specific error message names the cause and a working fix if the interpreter still crashes in `site` (#1783)
- Exports and other native-picker actions no longer 403 with "Invalid or expired desktop authorization" when the desktop app and backend resolve different data directories, e.g. dev mode or a custom data folder (#1781)
diff --git a/frontend/src/pages/Transcriptions.jsx b/frontend/src/pages/Transcriptions.jsx
index 9ab3237e..608fcebc 100644
--- a/frontend/src/pages/Transcriptions.jsx
+++ b/frontend/src/pages/Transcriptions.jsx
@@ -26,6 +26,23 @@ function saveTranscriptions(list) {
localStorage.setItem(TRANSCRIPTIONS_KEY, JSON.stringify(list));
}
+/** A segment's "12.0s – 15.5s" label, tolerant of missing timings (#1798).
+ *
+ * Not every ASR path produces a timed segment: an OpenAI-compatible backend
+ * answering in `json`/`text` format has no timings at all, and
+ * `services/asr_backend.py` records that honestly as `end: None` rather than
+ * inventing a number. Calling `.toFixed()` on it threw during render and took
+ * the whole Transcriptions view down, so a transcript that merely lacked
+ * timings became one the user could not read at all. Render whichever half is
+ * known, and nothing when neither is. */
+export function segTimeRange(seg) {
+ const known = (v) => typeof v === 'number' && Number.isFinite(v);
+ const start = known(seg?.start) ? `${seg.start.toFixed(1)}s` : null;
+ const end = known(seg?.end) ? `${seg.end.toFixed(1)}s` : null;
+ if (start && end) return `${start} – ${end}`;
+ return start || end || '';
+}
+
export function addTranscription(entry) {
const list = loadTranscriptions();
const newEntry = {
@@ -288,7 +305,7 @@ export default function TranscriptionsPage() {
className="txn-detail__seg flex gap-[8px] py-[3px] text-[var(--text-xs)]"
>
- {seg.start.toFixed(1)}s – {seg.end.toFixed(1)}s
+ {segTimeRange(seg)}
{seg.text}
diff --git a/frontend/src/pages/Transcriptions.test.jsx b/frontend/src/pages/Transcriptions.test.jsx
index fd4cd3de..55bb28fb 100644
--- a/frontend/src/pages/Transcriptions.test.jsx
+++ b/frontend/src/pages/Transcriptions.test.jsx
@@ -19,7 +19,7 @@ vi.mock('../hooks/useEffectiveDictationShortcut', () => ({
}));
vi.mock('react-hot-toast', () => ({ toast }));
-import TranscriptionsPage, { addTranscription } from './Transcriptions';
+import TranscriptionsPage, { addTranscription, segTimeRange } from './Transcriptions';
describe('Transcriptions capture entry point', () => {
beforeEach(() => {
@@ -67,3 +67,39 @@ describe('Transcriptions capture entry point', () => {
expect(await screen.findByText('The shared capture path works.')).toBeInTheDocument();
});
});
+
+// #1798: an OpenAI-compatible ASR answering in json/text format returns no
+// timings, and services/asr_backend.py records that honestly as `end: null`
+// rather than inventing a number. The segment list called `.toFixed()` on it
+// unconditionally, which threw during render and took the whole
+// Transcriptions view down — a transcript that merely lacked timings became
+// one the user could not read at all.
+describe('segments without timings (#1798)', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it('renders a segment whose end is null instead of crashing the view', async () => {
+ addTranscription({
+ text: 'hello from an untimed backend',
+ language: 'en',
+ segments: [{ text: 'hello from an untimed backend', start: 0, end: null }],
+ });
+
+ render();
+ fireEvent.click(await screen.findByText('hello from an untimed backend'));
+
+ // The transcript itself must still be readable — this is the regression:
+ // before the guard, the null `end` threw and nothing rendered at all.
+ expect(screen.getAllByText('hello from an untimed backend').length).toBeGreaterThan(0);
+ });
+
+ it('formats what is known and never prints NaN', () => {
+ expect(segTimeRange({ start: 12, end: 15.55 })).toBe('12.0s – 15.6s');
+ expect(segTimeRange({ start: 0, end: null })).toBe('0.0s');
+ expect(segTimeRange({ start: null, end: 4 })).toBe('4.0s');
+ expect(segTimeRange({ text: 'no timings' })).toBe('');
+ expect(segTimeRange(undefined)).toBe('');
+ expect(segTimeRange({ start: NaN, end: Infinity })).toBe('');
+ });
+});