diff --git a/src/apps/officer-web/Screens/Dashboard/Jobs/JobDetail.tsx b/src/apps/officer-web/Screens/Dashboard/Jobs/JobDetail.tsx
index 399157b5..b6fc1ee6 100644
--- a/src/apps/officer-web/Screens/Dashboard/Jobs/JobDetail.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Jobs/JobDetail.tsx
@@ -9,6 +9,7 @@ import { Card } from '@/components/Card';
import { Button } from '@/components/ui/button';
import { WorkspaceLayout } from 'officerdev';
import type { LayoutNode, PanelComponents } from 'officerdev';
+import { randomId } from 'helpers/random-id';
type Cost = { inputTokens: number; outputTokens: number; totalUSD: number };
@@ -571,7 +572,7 @@ export const PipelineJobDetail = () => {
const key = outputKey(msg.stepIndex, msg.iterationLabel);
const text = msg.text || streamBuffers.current.get(key) || '';
if (text) {
- appendOutput(key, { id: crypto.randomUUID(), type: 'text', text });
+ appendOutput(key, { id: randomId(), type: 'text', text });
}
streamBuffers.current.delete(key);
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; });
@@ -583,7 +584,7 @@ export const PipelineJobDetail = () => {
// Flush any streaming text before the tool call
flushStreamBuffer(key);
appendOutput(key, {
- id: crypto.randomUUID(),
+ id: randomId(),
type: 'tool',
toolCallId: msg.toolCallId,
toolName: msg.toolName,
@@ -630,7 +631,7 @@ export const PipelineJobDetail = () => {
const flushStreamBuffer = useCallback((key: string) => {
const text = streamBuffers.current.get(key);
if (text) {
- appendOutput(key, { id: crypto.randomUUID(), type: 'text', text });
+ appendOutput(key, { id: randomId(), type: 'text', text });
streamBuffers.current.delete(key);
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; });
}
diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts
index 4faac0f3..3660b0e0 100644
--- a/src/apps/officer-web/state/usePageTitle.ts
+++ b/src/apps/officer-web/state/usePageTitle.ts
@@ -3,6 +3,7 @@ import { useLocation } from 'react-router';
import type { PageTitleOverride } from 'officerdev';
import { usePageTitleOverride } from 'officerdev';
import { useSessionState, writeSessionValue } from 'hooks/useSessionState';
+import { randomId } from 'helpers/random-id';
type TitleRule = { match: (p: string) => boolean; title: string };
@@ -138,7 +139,7 @@ function claimTabIdentity(): void {
/** `randomUUID` needs a secure context; the id only has to be unique among open tabs. */
function newTabId(): string {
- return crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
+ return randomId();
}
// Once per document, before React reads the stored name.
diff --git a/src/workspaces/helpers/passkeys.ts b/src/workspaces/helpers/passkeys.ts
index 14f3fa6c..e16882f4 100644
--- a/src/workspaces/helpers/passkeys.ts
+++ b/src/workspaces/helpers/passkeys.ts
@@ -1,3 +1,4 @@
+import { randomId } from 'helpers/random-id';
// Frontend
type PasskeyUser = {
@@ -49,7 +50,7 @@ export function getSigninPayload(challenge: number[], credentialIds: string[]) {
// Backend
export function createChallenge() {
- const challenge = crypto.randomUUID();
+ const challenge = randomId();
const hex = challenge.replace(/-/g, '');
const array: number[] = new Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
diff --git a/src/workspaces/helpers/random-id.ts b/src/workspaces/helpers/random-id.ts
new file mode 100644
index 00000000..8d4f06b7
--- /dev/null
+++ b/src/workspaces/helpers/random-id.ts
@@ -0,0 +1,48 @@
+/**
+ * A UUID v4, in a browser that may not be in a secure context.
+ *
+ * ── Why this exists ──
+ *
+ * `crypto.randomUUID()` is SECURE-CONTEXT ONLY. Over plain http on anything that
+ * is not localhost it is not defined at all, and calling it throws
+ * `TypeError: crypto.randomUUID is not a function`.
+ *
+ * Officer is reached over the tailnet — `http://officer-dev:9000` — which is
+ * neither localhost nor https, so every one of these threw. Worst inside a
+ * `useState` initialiser, where the throw happens during render and takes the
+ * whole tree down: chat crashed at the end of every turn, on the assistant
+ * message that did not have an id yet.
+ *
+ * `crypto.getRandomValues()` carries NO such restriction — it is on `Crypto`,
+ * not on `SubtleCrypto`, and works in an insecure context. So the randomness
+ * below is exactly what `randomUUID` would have given; only the convenience
+ * wrapper was missing.
+ *
+ * ── This is not a weakening ──
+ *
+ * Same CSPRNG, same 122 bits of entropy, same version and variant bits. When
+ * `randomUUID` exists it is used unchanged; otherwise the value is assembled by
+ * hand from the source `randomUUID` itself draws on.
+ *
+ * The final fallback is `Math.random`, and it is there for completeness rather
+ * than for use: a browser with no `crypto` object at all cannot run this app.
+ * Never reached in practice, and marked so nobody mistakes it for a supported
+ * path or copies it somewhere it would matter.
+ */
+export function randomId(): string {
+ const c = globalThis.crypto;
+
+ if (typeof c?.randomUUID === 'function') return c.randomUUID();
+
+ if (typeof c?.getRandomValues === 'function') {
+ const bytes = c.getRandomValues(new Uint8Array(16));
+ // Version 4, and the RFC 4122 variant. Exactly what randomUUID sets.
+ bytes[6] = (bytes[6]! & 0x0f) | 0x40;
+ bytes[8] = (bytes[8]! & 0x3f) | 0x80;
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
+ }
+
+ // Unreachable in any browser that can run this app. Not a supported path.
+ return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`;
+}
diff --git a/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx b/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx
index e3e6ed1e..7792b624 100644
--- a/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx
+++ b/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx
@@ -27,6 +27,7 @@ import { getRawUrl } from '../../FileViewer/file-types';
import { useFilesAPI } from '../../../hooks/useFilesAPI';
import { CopyButton } from './CopyButton';
import { CodeBlock } from './CodeBlock';
+import { randomId } from 'helpers/random-id';
/** Shared by the settled bubble and the streaming one, so a block gains nothing when the turn ends. */
const MD_COMPONENTS = { pre: CodeBlock };
@@ -202,7 +203,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, defaultOpen }: Messa
-
+
diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx
index 1a24aee3..505b1e17 100644
--- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx
+++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx
@@ -20,6 +20,7 @@ import {
type FolderProbe,
type FolderTrackGroup,
} from '../../../../hooks/useFilesAPI';
+import { randomId } from 'helpers/random-id';
const playDing = () => {
const ctx = new AudioContext();
@@ -129,7 +130,7 @@ const AgenticTaskRunner = ({
const text = lastStreamRef.current;
const isDuplicate = accRef.current.some((a) => a.role === 'assistant' && 'text' in a && a.text === text);
if (!isDuplicate) {
- accRef.current.push({ role: 'assistant', id: crypto.randomUUID(), text });
+ accRef.current.push({ role: 'assistant', id: randomId(), text });
bump((n) => n + 1);
}
lastStreamRef.current = '';
diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/usePipelineRunner.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/usePipelineRunner.ts
index cbf37899..63e4244d 100644
--- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/usePipelineRunner.ts
+++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/usePipelineRunner.ts
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import type { ChatMessage } from '../../../Chat';
+import { randomId } from 'helpers/random-id';
type Phase = 'ready' | 'running' | 'done';
@@ -76,7 +77,7 @@ export function usePipelineRunner() {
const flushStream = useCallback(() => {
const text = streamBufferRef.current;
if (text) {
- setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text }]);
+ setMessages((prev) => [...prev, { role: 'assistant', id: randomId(), text }]);
streamBufferRef.current = '';
setStreamingText('');
}
@@ -208,7 +209,7 @@ export function usePipelineRunner() {
if (inParallelRef.current && msg.iterationLabel) break;
const text = msg.text || streamBufferRef.current;
if (text) {
- setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text }]);
+ setMessages((prev) => [...prev, { role: 'assistant', id: randomId(), text }]);
}
streamBufferRef.current = '';
setStreamingText('');
@@ -223,7 +224,7 @@ export function usePipelineRunner() {
...prev,
{
role: 'tool' as const,
- id: crypto.randomUUID(),
+ id: randomId(),
toolCallId: msg.toolCallId,
toolName: msg.toolName,
toolInput: msg.toolInput,
@@ -253,7 +254,7 @@ export function usePipelineRunner() {
case 'error':
flushStream();
- setMessages((prev) => [...prev, { role: 'error' as const, id: crypto.randomUUID(), text: msg.message }]);
+ setMessages((prev) => [...prev, { role: 'error' as const, id: randomId(), text: msg.message }]);
setHasError(true);
setPhase('done');
stopTimer();
diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx
index 4cb50438..b715ae31 100644
--- a/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx
+++ b/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx
@@ -5,6 +5,7 @@ import { toast } from 'sonner';
import { Search, Loader2, RefreshCw, X, History, FileAudio, Users, ChevronRight, Trash2 } from 'lucide-react';
import { SearchResults, dropCachedResults } from './SearchResults';
import { formatWhen, SEARCH_PARAM, type SlskdSearchSummary } from './shared';
+import { randomId } from 'helpers/random-id';
// The 'search' section — a search input plus the history of past searches (GET /searches). Which search
// is open lives in `?search=`, so rows are real links and the back button returns to the history.
@@ -95,7 +96,7 @@ export const SearchView = () => {
const text = query.trim();
if (!text || submitting) return;
setSubmitting(true);
- const sentId = crypto.randomUUID();
+ const sentId = randomId();
try {
const created = await client.post>('/slskd/api/v0/searches', {
id: sentId,
diff --git a/src/workspaces/officerdev/src/apps/Terminal/use-terminal-session.ts b/src/workspaces/officerdev/src/apps/Terminal/use-terminal-session.ts
index 82c65a19..f1306e1b 100644
--- a/src/workspaces/officerdev/src/apps/Terminal/use-terminal-session.ts
+++ b/src/workspaces/officerdev/src/apps/Terminal/use-terminal-session.ts
@@ -4,11 +4,12 @@ import { useClient } from 'hooks/useClient';
import { useDashboardState } from 'state/useDashboardState';
import { usePanelClose } from '../../components/Workspace';
+import { randomId } from 'helpers/random-id';
const EMPTY_TERMINALS: Record = {};
const newSessionId = (): string =>
- crypto.randomUUID?.() ?? Math.random().toString(36).slice(2) + Date.now().toString(36);
+ randomId() ?? Math.random().toString(36).slice(2) + Date.now().toString(36);
/**
* The shell a terminal panel is attached to: minted on first mount, remembered until the panel is closed.
diff --git a/src/workspaces/officerdev/src/hooks/useChat.ts b/src/workspaces/officerdev/src/hooks/useChat.ts
index 57c98b2b..cb93c1a6 100644
--- a/src/workspaces/officerdev/src/hooks/useChat.ts
+++ b/src/workspaces/officerdev/src/hooks/useChat.ts
@@ -4,6 +4,7 @@ import { useClient } from 'hooks/useClient';
import { useSettings } from 'state/useSettings';
import type { ChatMessage, ServerMessage, TaskInfo, Message } from '../apps/Chat/types';
import { spliceRunningTasks } from '../apps/Chat/running-tasks';
+import { randomId } from 'helpers/random-id';
const SAVE_DEBOUNCE_MS = 1000;
const OLDER_PAGE_SIZE = 20; // messages fetched per scroll-up (matches the initial tail window)
@@ -182,7 +183,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
const text = streamingRef.current;
streamingRef.current = '';
setStreamingText('');
- setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text }]);
+ setMessages((prev) => [...prev, { role: 'assistant', id: randomId(), text }]);
}
/**
@@ -226,7 +227,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
if (call.toolCallId) byToolCallId.set(call.toolCallId, call);
converted = call;
} else {
- converted = { role: 'assistant', id: m.id ?? crypto.randomUUID(), text: m.text || '' };
+ converted = { role: 'assistant', id: m.id ?? randomId(), text: m.text || '' };
}
// A subagent's output belongs under the Task row that spawned it. If that row is missing (pruned
@@ -294,15 +295,15 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
(prev) =>
withChildren(prev, parent, (kids) => [
...kids,
- { role: 'assistant', id: crypto.randomUUID(), text: msg.text },
- ]) ?? [...prev, { role: 'assistant', id: crypto.randomUUID(), text: msg.text }],
+ { role: 'assistant', id: randomId(), text: msg.text },
+ ]) ?? [...prev, { role: 'assistant', id: randomId(), text: msg.text }],
);
break;
}
if (streamingRef.current) {
commitStreaming();
} else {
- setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text: msg.text }]);
+ setMessages((prev) => [...prev, { role: 'assistant', id: randomId(), text: msg.text }]);
}
break;
}