From 131ad52411277c21c341e0b70e0323755775f7c3 Mon Sep 17 00:00:00 2001 From: Laurent-Philippe Albou <24249870+lpalbou@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:54:17 +0200 Subject: [PATCH 01/14] fix: plain-http (non-secure context) support: request/session/switch-case ids via ui-kit randomId (0.3.3), every Copy through clipboardWrite with Copied / Copy failed feedback (copy_feedback.ts), voice wait shows the https sentence with Record disabled; src/lib/secure-context.ts (shared copy), test red without the fix; CHANGELOG [Unreleased] --- CHANGELOG.md | 11 ++ src/components/AuthoringAssistantDrawer.tsx | 7 +- src/components/JsonViewer.tsx | 12 +- src/components/MarkdownRenderer.tsx | 17 +- src/components/PropertiesPanel.tsx | 6 +- src/components/RunFlowModal.tsx | 43 ++--- src/components/RunSwitcherDropdown.tsx | 12 +- src/components/Toolbar.tsx | 23 +-- .../assistant/AssistantStatusCard.tsx | 5 +- src/components/nodes/BaseNode.tsx | 5 +- src/hooks/useWebSocket.ts | 6 +- src/lib/copy_feedback.ts | 16 ++ src/lib/secure-context.ts | 151 ++++++++++++++++++ src/lib/secure_context.test.ts | 55 +++++++ src/utils/gatewayClient.ts | 10 +- 15 files changed, 277 insertions(+), 102 deletions(-) create mode 100644 src/lib/copy_feedback.ts create mode 100644 src/lib/secure-context.ts create mode 100644 src/lib/secure_context.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cf9e07db..4d2b37bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Plain http from another machine** (LAN, Tailscale). Browsers withhold + `crypto.randomUUID`, the clipboard API and the microphone outside https and + localhost. Request, session and switch-case ids now come from the kit's + `randomId()` (v4 UUIDs everywhere), every **Copy** falls back to a + text-selection copy and says "Copied" or "Copy failed — select and copy", + and a voice wait shows "Voice and camera need an https address (Network → + HTTPS in the gateway console)." with **Record** disabled. Needs + `@abstractframework/ui-kit` 0.3.3. + ## [0.5.0] - 2026-09-30 The editor works on phones, tablets, and browser windows of any size. On a diff --git a/src/components/AuthoringAssistantDrawer.tsx b/src/components/AuthoringAssistantDrawer.tsx index 9c787700..d9c1d2d9 100644 --- a/src/components/AuthoringAssistantDrawer.tsx +++ b/src/components/AuthoringAssistantDrawer.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from 'react'; import { useQuery } from '@tanstack/react-query'; import toast from 'react-hot-toast'; +import { clipboardWriteOrThrow } from '../lib/copy_feedback'; import authoringSkillText from '../../docs/workflow-authoring-skill.md?raw'; // Conceptual core docs the assistant uses to EXPLAIN AbstractFlow to users and // to author with real product grounding (operator ask 2026-07-15). Kept as raw @@ -3894,7 +3895,7 @@ export function AuthoringAssistantDrawer({ const copyActivity = async () => { try { - await navigator.clipboard.writeText( + await clipboardWriteOrThrow( activityClipboardText(workingStatus?.label || 'Authoring activity', activity, turnStartedAt) ); toast.success('Authoring activity copied'); @@ -3905,7 +3906,7 @@ export function AuthoringAssistantDrawer({ const copyConversation = async () => { try { - await navigator.clipboard.writeText( + await clipboardWriteOrThrow( assistantConversationClipboardText({ workflowKey: workflowStorageKey, flowId, @@ -4061,7 +4062,7 @@ export function AuthoringAssistantDrawer({ onAskReplyChange={setTestAskReply} onSendAskReply={() => void resolveTestWait({ response: testAskReply })} onOpenRun={(runId) => { - void navigator.clipboard.writeText(runId).then( + void clipboardWriteOrThrow(runId).then( () => toast.success('Run id copied — open it from Run History'), () => toast(`Run id: ${runId}`) ); diff --git a/src/components/JsonViewer.tsx b/src/components/JsonViewer.tsx index 0dde43b6..29aeb276 100644 --- a/src/components/JsonViewer.tsx +++ b/src/components/JsonViewer.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import clsx from 'clsx'; +import { copyWithFeedback } from '../lib/copy_feedback'; const DEFAULT_FOLDED_DEPTH = 3; const UNFOLDED_DEPTH = Number.MAX_SAFE_INTEGER; @@ -54,16 +55,7 @@ function copyStringForJson(value: unknown): string { async function copyText(text: string): Promise { const value = String(text || ''); if (!value) return; - try { - await navigator.clipboard.writeText(value); - } catch { - const el = document.createElement('textarea'); - el.value = value; - document.body.appendChild(el); - el.select(); - document.execCommand('copy'); - document.body.removeChild(el); - } + await copyWithFeedback(value); } function Token({ diff --git a/src/components/MarkdownRenderer.tsx b/src/components/MarkdownRenderer.tsx index 818dc204..7bdb3174 100644 --- a/src/components/MarkdownRenderer.tsx +++ b/src/components/MarkdownRenderer.tsx @@ -3,6 +3,7 @@ import DOMPurify from 'dompurify'; import { marked } from 'marked'; import { useMonaco } from '@monaco-editor/react'; import { THEME_SPECS } from '@abstractframework/ui-kit'; +import { clipboardWrite, COPY_FAILED } from '../lib/secure-context'; /** * Pick the Monaco colorize theme from the applied app theme class @@ -147,16 +148,12 @@ export function MarkdownRenderer({ markdown, className }: MarkdownRendererProps) const text = (codeEl ? rawCodeByElRef.current.get(codeEl) : null) || codeEl?.textContent || ''; if (!text) return; - try { - await navigator.clipboard.writeText(text); - // Best-effort feedback (no dependency on toast) - btn.textContent = 'Copied'; - window.setTimeout(() => { - if (btn) btn.textContent = 'Copy'; - }, 900); - } catch { - // Ignore - } + // Feedback on the button itself (no dependency on toast); plain http falls back to execCommand. + const ok = await clipboardWrite(text); + btn.textContent = ok ? 'Copied' : COPY_FAILED; + window.setTimeout(() => { + if (btn) btn.textContent = 'Copy'; + }, ok ? 900 : 2500); }; return ( diff --git a/src/components/PropertiesPanel.tsx b/src/components/PropertiesPanel.tsx index d8cb0f12..c1ec5e3f 100644 --- a/src/components/PropertiesPanel.tsx +++ b/src/components/PropertiesPanel.tsx @@ -6,7 +6,7 @@ import React, { useCallback, useEffect, useState, useRef } from 'react'; import { useQuery } from '@tanstack/react-query'; import type { Node } from 'reactflow'; import toast from 'react-hot-toast'; -import { SpeculationSelect, normalizeSpeculationValue, type SpeculationValue } from '@abstractframework/ui-kit'; +import { SpeculationSelect, normalizeSpeculationValue, randomId, type SpeculationValue } from '@abstractframework/ui-kit'; import type { FlowNodeData, JsonValue, ProviderInfo, VisualFlow, Pin } from '../types/flow'; import { getBundledFlow } from '../utils/bundledFlows'; import { RECALL_LEVEL_OPTIONS } from '../types/recall'; @@ -3457,9 +3457,7 @@ export function PropertiesPanel({ node }: PropertiesPanelProps) { const addCase = () => { const id = - typeof crypto !== 'undefined' && 'randomUUID' in crypto - ? (crypto.randomUUID() as string).slice(0, 8) - : `c${Date.now().toString(16)}${Math.random().toString(16).slice(2, 6)}`; + randomId().slice(0, 8); const nextCases = [...cases, { id, value: '' }]; updateNodeData(node.id, { diff --git a/src/components/RunFlowModal.tsx b/src/components/RunFlowModal.tsx index 3701fab1..8a01741b 100644 --- a/src/components/RunFlowModal.tsx +++ b/src/components/RunFlowModal.tsx @@ -8,6 +8,9 @@ import { useState, useCallback, useMemo, useEffect, useRef, type DragEvent, type FormEvent, type MouseEvent, type ReactNode } from 'react'; import { useQuery } from '@tanstack/react-query'; import toast from 'react-hot-toast'; +import { randomId } from '@abstractframework/ui-kit'; +import { copyWithFeedback } from '../lib/copy_feedback'; +import { MEDIA_NEEDS_HTTPS, mediaAvailable } from '../lib/secure-context'; import { useFlowStore } from '../hooks/useFlow'; import { useExecutionCapabilities } from '../hooks/useExecutionCapabilities'; import type { ExecutionEvent, ExecutionMetrics, Pin, FlowRunResult, RunSummary } from '../types/flow'; @@ -195,11 +198,7 @@ function approvalDismissKey(runId?: string, waitKey?: string): string { } function createRunModalSessionId(): string { - const random = - typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' - ? crypto.randomUUID() - : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; - return `abstractflow-run-${random}`; + return `abstractflow-run-${randomId()}`; } /** Short, human-scannable session/run id (keeps the tail so a copy stays @@ -372,16 +371,7 @@ function durableBlocEndpoint( async function copyTextToClipboard(text: string): Promise { const value = String(text || ''); if (!value) return; - try { - await navigator.clipboard.writeText(value); - } catch { - const el = document.createElement('textarea'); - el.value = value; - document.body.appendChild(el); - el.select(); - document.execCommand('copy'); - document.body.removeChild(el); - } + await copyWithFeedback(value); } function isExecutionHandle(value: unknown): boolean { @@ -5486,18 +5476,7 @@ export function RunFlowModal({ ]); const copyToClipboard = async (value: unknown) => { - const text = formatValue(value); - try { - await navigator.clipboard.writeText(text); - } catch { - // Fallback: best-effort legacy copy - const el = document.createElement('textarea'); - el.value = text; - document.body.appendChild(el); - el.select(); - document.execCommand('copy'); - document.body.removeChild(el); - } + await copyWithFeedback(formatValue(value)); }; const outputPreview = useMemo(() => { @@ -6298,8 +6277,8 @@ export function RunFlowModal({ const startVoiceWaitRecording = useCallback(async () => { if (voiceWaitRecording || voiceWaitBusy || resumeSubmitting) return; - if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) { - setVoiceWaitError('This browser cannot record microphone audio.'); + if (!mediaAvailable()) { + setVoiceWaitError(MEDIA_NEEDS_HTTPS); return; } if (typeof MediaRecorder === 'undefined') { @@ -7018,7 +6997,9 @@ export function RunFlowModal({ ? 'Uploading audio...' : voiceWaitUploadedRef ? 'Audio captured.' - : 'Ready to record from this browser.'} + : mediaAvailable() + ? 'Ready to record from this browser.' + : MEDIA_NEEDS_HTTPS}
{!voiceWaitRecording ? ( @@ -7026,7 +7007,7 @@ export function RunFlowModal({ type="button" className="modal-button primary" onClick={startVoiceWaitRecording} - disabled={voiceWaitBusy || resumeSubmitting} + disabled={voiceWaitBusy || resumeSubmitting || !mediaAvailable()} > Record diff --git a/src/components/RunSwitcherDropdown.tsx b/src/components/RunSwitcherDropdown.tsx index 142a4b37..580fa321 100644 --- a/src/components/RunSwitcherDropdown.tsx +++ b/src/components/RunSwitcherDropdown.tsx @@ -5,6 +5,7 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { RunSummary } from '../types/flow'; +import { copyWithFeedback } from '../lib/copy_feedback'; import { filterRunSummariesByFlowId, mapGatewayRunSummary } from '../utils/gatewayRuns'; import { endpointFromDescriptor, gatewayJson, type GatewayContracts } from '../utils/gatewayClient'; @@ -38,16 +39,7 @@ function formatRunTime(iso?: string | null): string { async function copyText(text: string): Promise { const value = String(text || ''); if (!value) return; - try { - await navigator.clipboard.writeText(value); - } catch { - const el = document.createElement('textarea'); - el.value = value; - document.body.appendChild(el); - el.select(); - document.execCommand('copy'); - document.body.removeChild(el); - } + await copyWithFeedback(value, 'Run id copied'); } export function RunSwitcherDropdown({ diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index cb69e76b..514c6cf5 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import toast from 'react-hot-toast'; +import { copyWithFeedback } from '../lib/copy_feedback'; import { loadEdgeNotice, useFlowStore } from '../hooks/useFlow'; import { useWebSocket } from '../hooks/useWebSocket'; import { RunFlowModal } from './RunFlowModal'; @@ -528,20 +529,6 @@ export function Toolbar() { } }, []); - const copyTextToClipboard = useCallback(async (text: string) => { - try { - await navigator.clipboard.writeText(text); - } catch { - // Fallback: best-effort legacy copy - const el = document.createElement('textarea'); - el.value = text; - document.body.appendChild(el); - el.select(); - document.execCommand('copy'); - document.body.removeChild(el); - } - }, []); - const showWorkflowFailedToast = useCallback( (fullError: unknown) => { const full = formatValue(fullError) || 'Unknown error'; @@ -555,16 +542,14 @@ export function Toolbar() { style={{ cursor: 'pointer' }} onClick={() => { void (async () => { - await copyTextToClipboard(full); - toast.success('Copied error to clipboard'); + await copyWithFeedback(full, 'Copied error to clipboard'); })(); }} onKeyDown={(e) => { if (e.key !== 'Enter' && e.key !== ' ') return; e.preventDefault(); void (async () => { - await copyTextToClipboard(full); - toast.success('Copied error to clipboard'); + await copyWithFeedback(full, 'Copied error to clipboard'); })(); }} > @@ -576,7 +561,7 @@ export function Toolbar() {
); }, - [copyTextToClipboard, formatValue] + [formatValue] ); async function fetchRunHistory(runId: string): Promise { diff --git a/src/components/assistant/AssistantStatusCard.tsx b/src/components/assistant/AssistantStatusCard.tsx index 57ffd79e..d2c68ead 100644 --- a/src/components/assistant/AssistantStatusCard.tsx +++ b/src/components/assistant/AssistantStatusCard.tsx @@ -12,6 +12,7 @@ import { } from './assistantActivity'; import { formatTokenCount } from '../../utils/plannerUsage'; import { IconChevron, IconCopy } from './icons'; +import { copyWithFeedback } from '../../lib/copy_feedback'; interface AssistantStatusCardProps { workingStatus: WorkingStatus; @@ -177,13 +178,13 @@ export function AssistantStatusCard({ onClick={(event) => { event.preventDefault(); event.stopPropagation(); - void navigator.clipboard.writeText(entry.detail || ''); + void copyWithFeedback(entry.detail || ''); }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); - void navigator.clipboard.writeText(entry.detail || ''); + void copyWithFeedback(entry.detail || ''); } }} title="Copy full payload to clipboard" diff --git a/src/components/nodes/BaseNode.tsx b/src/components/nodes/BaseNode.tsx index abe1d9ca..44d01d7b 100644 --- a/src/components/nodes/BaseNode.tsx +++ b/src/components/nodes/BaseNode.tsx @@ -11,6 +11,7 @@ import { useQuery } from '@tanstack/react-query'; import { Handle, Position, NodeProps, useUpdateNodeInternals } from 'reactflow'; import { clsx } from 'clsx'; import toast from 'react-hot-toast'; +import { randomId } from '@abstractframework/ui-kit'; import type { FlowNodeData, JsonValue, Pin, PinType, VisualFlow } from '../../types/flow'; import { getBundledFlow } from '../../utils/bundledFlows'; import { PIN_COLORS, isEntryNodeType } from '../../types/flow'; @@ -3810,9 +3811,7 @@ export const BaseNode = memo(function BaseNode({ const existingCases = data.switchConfig?.cases ?? []; const newId = - typeof crypto !== 'undefined' && 'randomUUID' in crypto - ? (crypto.randomUUID() as string).slice(0, 8) - : `c${Date.now().toString(16)}${Math.random().toString(16).slice(2, 6)}`; + randomId().slice(0, 8); const nextCases = [...existingCases, { id: newId, value: '' }]; diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index 30c2524b..5a76e8b8 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ExecutionEvent } from '../types/flow'; +import { randomId } from '@abstractframework/ui-kit'; import { useFlowStore } from './useFlow'; import { closeOpenNodes, @@ -38,10 +39,7 @@ function getOrCreateStableSessionId(): string | undefined { const existing = window.sessionStorage.getItem(STABLE_SESSION_ID_KEY); if (existing && existing.trim()) return existing.trim(); - const next = - typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' - ? crypto.randomUUID() - : `af_${Math.random().toString(16).slice(2)}_${Date.now().toString(16)}`; + const next = randomId(); window.sessionStorage.setItem(STABLE_SESSION_ID_KEY, next); return next; } catch { diff --git a/src/lib/copy_feedback.ts b/src/lib/copy_feedback.ts new file mode 100644 index 00000000..352faf2b --- /dev/null +++ b/src/lib/copy_feedback.ts @@ -0,0 +1,16 @@ +import toast from 'react-hot-toast'; + +import { clipboardWrite, COPY_FAILED } from './secure-context'; + +/** Copy text and say whether it worked: a success toast, or COPY_FAILED (plain http without a clipboard). */ +export async function copyWithFeedback(text: string, copiedLabel = 'Copied'): Promise { + const ok = await clipboardWrite(text); + if (ok) toast.success(copiedLabel); + else toast.error(COPY_FAILED); + return ok; +} + +/** clipboardWrite for call sites that already handle a rejection with their own message. */ +export async function clipboardWriteOrThrow(text: string): Promise { + if (!(await clipboardWrite(text))) throw new Error(COPY_FAILED); +} diff --git a/src/lib/secure-context.ts b/src/lib/secure-context.ts new file mode 100644 index 00000000..172d6f90 --- /dev/null +++ b/src/lib/secure-context.ts @@ -0,0 +1,151 @@ +/* + * Secure-context fallbacks (identical copy in every app: src/lib/secure-context.ts). + * + * Browsers expose navigator.clipboard, navigator.mediaDevices.getUserMedia, + * crypto.subtle and crypto.randomUUID only in a secure context (https or + * localhost). Apps are often opened over plain http from another machine + * (LAN, Tailscale), where those are missing. Ids come from the kit's + * randomId(); this file covers the rest: + * - clipboardWrite(): navigator.clipboard when present, else a hidden + * textarea + document.execCommand("copy"); returns whether it worked so + * the UI can say "Copied" or COPY_FAILED. + * - mediaAvailable() / MEDIA_NEEDS_HTTPS: the one sentence to show on a + * voice or camera control when the browser withholds the microphone. + * - sha256Hex(): crypto.subtle when present, else a plain SHA-256. + * The kit may lift this file later; keep every copy identical. + */ + +/** Shown on a voice or camera control when getUserMedia is unavailable. */ +export const MEDIA_NEEDS_HTTPS = "Voice and camera need an https address (Network → HTTPS in the gateway console)."; + +/** Shown after a copy that did not reach the clipboard. */ +export const COPY_FAILED = "Copy failed — select and copy"; + +/** True on https or localhost; false over plain http from another machine. */ +export function secureContext(): boolean { + const v = (globalThis as { isSecureContext?: unknown }).isSecureContext; + return typeof v === "boolean" ? v : true; +} + +/** True when the browser offers the microphone/camera API here. */ +export function mediaAvailable(): boolean { + const nav = (globalThis as { navigator?: { mediaDevices?: { getUserMedia?: unknown } } }).navigator; + return typeof nav?.mediaDevices?.getUserMedia === "function"; +} + +function execCommandCopy(text: string): boolean { + const doc = (globalThis as { document?: Document }).document; + if (!doc || !doc.body || typeof doc.execCommand !== "function") return false; + const el = doc.createElement("textarea"); + el.value = text; + el.setAttribute("readonly", ""); + el.style.position = "fixed"; + el.style.top = "0"; + el.style.left = "-9999px"; + el.style.opacity = "0"; + const active = doc.activeElement as HTMLElement | null; + doc.body.appendChild(el); + try { + el.select(); + el.setSelectionRange(0, text.length); + return doc.execCommand("copy") === true; + } catch { + return false; + } finally { + doc.body.removeChild(el); + try { + active?.focus?.(); + } catch { + // focus restore is best effort + } + } +} + +/** + * Copy text to the clipboard. Uses navigator.clipboard when present (secure + * contexts), else a hidden textarea + document.execCommand("copy") (works over + * plain http inside a click handler). Resolves true only when the copy worked. + */ +export async function clipboardWrite(text: string): Promise { + const value = String(text ?? ""); + const nav = (globalThis as { navigator?: { clipboard?: { writeText?: (t: string) => Promise } } }).navigator; + if (typeof nav?.clipboard?.writeText === "function") { + try { + await nav.clipboard.writeText(value); + return true; + } catch { + // permission denied or not focused: try the textarea path + } + } + return execCommandCopy(value); +} + +const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +/** Plain SHA-256 (FIPS 180-4) of bytes, as lowercase hex. */ +export function sha256HexSync(bytes: Uint8Array): string { + const len = bytes.length; + const padded = new Uint8Array(((len + 9 + 63) >> 6) << 6); + padded.set(bytes); + padded[len] = 0x80; + const view = new DataView(padded.buffer); + view.setUint32(padded.length - 8, Math.floor(len / 0x20000000), false); + view.setUint32(padded.length - 4, (len << 3) >>> 0, false); + const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]); + const w = new Uint32Array(64); + const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n)); + for (let off = 0; off < padded.length; off += 64) { + for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4, false); + for (let i = 16; i < 64; i++) { + const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3); + const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; + } + let [a, b, c, d, e, f, g, hh] = [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]]; + for (let i = 0; i < 64; i++) { + const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const ch = (e & f) ^ (~e & g); + const t1 = (hh + S1 + ch + K[i] + w[i]) >>> 0; + const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const t2 = (S0 + maj) >>> 0; + hh = g; + g = f; + f = e; + e = (d + t1) >>> 0; + d = c; + c = b; + b = a; + a = (t1 + t2) >>> 0; + } + h[0] = (h[0] + a) >>> 0; + h[1] = (h[1] + b) >>> 0; + h[2] = (h[2] + c) >>> 0; + h[3] = (h[3] + d) >>> 0; + h[4] = (h[4] + e) >>> 0; + h[5] = (h[5] + f) >>> 0; + h[6] = (h[6] + g) >>> 0; + h[7] = (h[7] + hh) >>> 0; + } + return Array.from(h, (x) => x.toString(16).padStart(8, "0")).join(""); +} + +/** SHA-256 of a UTF-8 string as lowercase hex: crypto.subtle when present, else sha256HexSync. */ +export async function sha256Hex(text: string): Promise { + const bytes = new TextEncoder().encode(String(text ?? "")); + const subtle = (globalThis as { crypto?: { subtle?: { digest?: (a: string, d: BufferSource) => Promise } } }).crypto?.subtle; + if (subtle && typeof subtle.digest === "function") { + const digest = await subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join(""); + } + return sha256HexSync(bytes); +} diff --git a/src/lib/secure_context.test.ts b/src/lib/secure_context.test.ts new file mode 100644 index 00000000..f83c9b4f --- /dev/null +++ b/src/lib/secure_context.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const toastMock = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); +vi.mock('react-hot-toast', () => ({ default: toastMock })); + +import { makeGatewayRequestId } from '../utils/gatewayClient'; +import { copyWithFeedback } from './copy_feedback'; +import { COPY_FAILED, MEDIA_NEEDS_HTTPS, mediaAvailable } from './secure-context'; + +// Plain http from another machine (LAN, Tailscale) is not a secure context: +// the browser withholds crypto.randomUUID, navigator.clipboard and +// getUserMedia. These tests run the id and copy paths with those removed. + +const V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const realCrypto = globalThis.crypto; + +afterEach(() => { + Object.defineProperty(globalThis, 'crypto', { value: realCrypto, configurable: true, writable: true }); + vi.unstubAllGlobals(); + toastMock.success.mockClear(); + toastMock.error.mockClear(); +}); + +describe('non-secure context (plain http)', () => { + it('request ids are distinct v4 UUIDs without crypto.randomUUID', () => { + Object.defineProperty(globalThis, 'crypto', { + value: { getRandomValues: (a: Uint8Array) => realCrypto.getRandomValues(a) }, + configurable: true, + writable: true, + }); + expect((globalThis.crypto as { randomUUID?: unknown }).randomUUID).toBeUndefined(); + const ids = Array.from({ length: 10000 }, () => makeGatewayRequestId()); + expect(ids.every((x) => V4.test(x))).toBe(true); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('copyWithFeedback falls back to execCommand and says whether it worked', async () => { + const textarea = { value: '', style: {}, setAttribute() {}, select() {}, setSelectionRange() {} }; + const execCommand = vi.fn(() => true); + vi.stubGlobal('navigator', {}); + vi.stubGlobal('document', { body: { appendChild() {}, removeChild() {} }, activeElement: null, createElement: () => textarea, execCommand }); + expect(await copyWithFeedback('run-1', 'Run id copied')).toBe(true); + expect(textarea.value).toBe('run-1'); + expect(toastMock.success).toHaveBeenCalledWith('Run id copied'); + execCommand.mockReturnValue(false); + expect(await copyWithFeedback('run-1')).toBe(false); + expect(toastMock.error).toHaveBeenCalledWith(COPY_FAILED); + }); + + it('mediaAvailable is false without getUserMedia and the sentence names the fix', () => { + vi.stubGlobal('navigator', {}); + expect(mediaAvailable()).toBe(false); + expect(MEDIA_NEEDS_HTTPS).toContain('https address'); + }); +}); diff --git a/src/utils/gatewayClient.ts b/src/utils/gatewayClient.ts index 3d195cf3..fa06c885 100644 --- a/src/utils/gatewayClient.ts +++ b/src/utils/gatewayClient.ts @@ -1,5 +1,5 @@ import type { GatewayAuthoringCapability } from './nodeCapabilities'; -import { GATEWAY_API_PATH, gatewayApiPath } from '@abstractframework/ui-kit'; +import { GATEWAY_API_PATH, gatewayApiPath, randomId } from '@abstractframework/ui-kit'; export type GatewayQueryValue = string | number | boolean | null | undefined; @@ -1295,9 +1295,7 @@ export async function gatewayRunLedger( return gatewayJson(endpoint); } -export function makeGatewayRequestId(prefix = 'gw'): string { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - return crypto.randomUUID(); - } - return `${prefix}_${Math.random().toString(16).slice(2)}_${Date.now().toString(16)}`; +/** A v4 UUID (kit randomId: works over plain http too). `prefix` is kept for callers; UUIDs need none. */ +export function makeGatewayRequestId(_prefix = 'gw'): string { + return randomId(); } From 3dd4035282aebc22b8e80321ed74b4672db10618 Mon Sep 17 00:00:00 2001 From: Laurent-Philippe Albou <24249870+lpalbou@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:54:37 +0200 Subject: [PATCH 02/14] state toggles: on/off node settings and dialog options are kit AfSwitch controls (Structured output, Recurrent, Free text answers, Reload gateway bundles, New folder per run, Durable, Lock after load, Cached and non-resident models); Pause/Resume on a running run marked as a one-shot action; tests incl. the kit verb-toggle guard over src --- src/components/ModelResidencyPanel.tsx | 27 ++++-- src/components/PropertiesPanel.tsx | 97 +++++++++----------- src/components/PublishFlowModal.tsx | 17 ++-- src/components/RunFlowModal.tsx | 52 ++++++----- src/components/stateToggles.test.tsx | 122 +++++++++++++++++++++++++ src/styles/index.css | 32 +------ 6 files changed, 224 insertions(+), 123 deletions(-) create mode 100644 src/components/stateToggles.test.tsx diff --git a/src/components/ModelResidencyPanel.tsx b/src/components/ModelResidencyPanel.tsx index a8af1c8a..dc595177 100644 --- a/src/components/ModelResidencyPanel.tsx +++ b/src/components/ModelResidencyPanel.tsx @@ -1,6 +1,7 @@ import { Fragment, useEffect, useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import toast from 'react-hot-toast'; +import { AfSwitch } from '@abstractframework/ui-kit'; import { useModels, useProviders } from '../hooks/useProviders'; import { TEXT_OUTPUT_CAPABILITY_ROUTE } from '../utils/capabilityRoutes'; import { @@ -833,19 +834,27 @@ export function ModelResidencyPanel({ isOpen, gatewayContracts, onClose }: Model
{lockAvailable ? ( - + ) : null} {loadEstimateHint ? {loadEstimateHint} : null}
- + {!showNonResident && hiddenCount > 0 ? ` ${hiddenCount} configuration/cache row${hiddenCount === 1 ? '' : 's'} hidden from this list.` : ''} @@ -871,7 +880,7 @@ export function ModelResidencyPanel({ isOpen, gatewayContracts, onClose }: Model {loadedQuery.isLoading ? 'Loading resident models…' : hiddenCount > 0 - ? 'No provider-resident models reported. Enable "Show cached/non-resident" to inspect cached and configuration rows.' + ? 'No provider-resident models reported. Switch "Cached and non-resident models" on to inspect cached and configuration rows.' : 'No provider-resident models reported.'}
) : ( diff --git a/src/components/PropertiesPanel.tsx b/src/components/PropertiesPanel.tsx index d8cb0f12..3eee5c73 100644 --- a/src/components/PropertiesPanel.tsx +++ b/src/components/PropertiesPanel.tsx @@ -6,7 +6,7 @@ import React, { useCallback, useEffect, useState, useRef } from 'react'; import { useQuery } from '@tanstack/react-query'; import type { Node } from 'reactflow'; import toast from 'react-hot-toast'; -import { SpeculationSelect, normalizeSpeculationValue, type SpeculationValue } from '@abstractframework/ui-kit'; +import { AfSwitch, SpeculationSelect, normalizeSpeculationValue, type SpeculationValue } from '@abstractframework/ui-kit'; import type { FlowNodeData, JsonValue, ProviderInfo, VisualFlow, Pin } from '../types/flow'; import { getBundledFlow } from '../utils/bundledFlows'; import { RECALL_LEVEL_OPTIONS } from '../types/recall'; @@ -4331,14 +4331,14 @@ export function PropertiesPanel({ node }: PropertiesPanelProps) {
- - + /> {agentSchemaEnabled && ( <> @@ -4945,28 +4941,22 @@ export function PropertiesPanel({ node }: PropertiesPanelProps) {
- - - - When enabled, the schedule re-arms after the branch completes. - + + updateNodeData(node.id, { + eventConfig: { + ...data.eventConfig, + recurrent, + }, + }) + } + />
)} @@ -6053,25 +6043,22 @@ export function PropertiesPanel({ node }: PropertiesPanelProps) { {data.nodeType === 'ask_user' && (
- - - If disabled, user must choose from provided choices - + + updateNodeData(node.id, { + effectConfig: { + ...data.effectConfig, + allowFreeText, + }, + }) + } + />
)} diff --git a/src/components/PublishFlowModal.tsx b/src/components/PublishFlowModal.tsx index 93fecff8..fbd05ecb 100644 --- a/src/components/PublishFlowModal.tsx +++ b/src/components/PublishFlowModal.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import toast from 'react-hot-toast'; +import { AfSwitch } from '@abstractframework/ui-kit'; import { capabilityUnavailable, endpointFromDescriptor, @@ -114,15 +115,17 @@ export function PublishFlowModal({ Leave empty to auto-bump (e.g., 0.0.1 → 0.0.2). - + {result ? (
diff --git a/src/components/RunFlowModal.tsx b/src/components/RunFlowModal.tsx index 3701fab1..a40ac314 100644 --- a/src/components/RunFlowModal.tsx +++ b/src/components/RunFlowModal.tsx @@ -16,7 +16,7 @@ import { RECALL_LEVEL_OPTIONS } from '../types/recall'; import type { WaitingInfo } from '../hooks/useWebSocket'; import { MarkdownRenderer } from './MarkdownRenderer'; import { AgentSubrunTracePanel } from './AgentSubrunTracePanel'; -import { SteerComposer, SpeculationSelect, type SpeculationValue } from '@abstractframework/ui-kit'; +import { AfSwitch, SteerComposer, SpeculationSelect, type SpeculationValue } from '@abstractframework/ui-kit'; import { parseSpeculationInput, withRunSpeculation } from '../utils/speculationControls'; import AfSelect from './inputs/AfSelect'; import AfMultiSelect from './inputs/AfMultiSelect'; @@ -5234,8 +5234,8 @@ export function RunFlowModal({ else onPause?.(); }} disabled={isPaused ? !isPaused : !(isRunning && !isWaiting)} - title={isPaused ? 'Resume' : 'Pause'} - aria-label={isPaused ? 'Resume run' : 'Pause run'} + title={isPaused ? 'Resume' : 'Pause'} // state-toggle-lint: allow one-shot action on a running run (not a saved setting) + aria-label={isPaused ? 'Resume run' : 'Pause run'} // state-toggle-lint: allow one-shot action on a running run (not a saved setting) > {isPaused ? '▶' : '⏸'} @@ -7124,10 +7124,14 @@ export function RunFlowModal({ spellCheck={false} />
- +
{!workspaceInputEnabled ? ( @@ -8765,7 +8769,7 @@ export function RunFlowModal({ }} disabled={cancelRequested || (isPaused ? !isPaused : !(isRunning && !isWaiting))} > - {isPaused ? 'Resume' : 'Pause'} + {isPaused ? 'Resume' : 'Pause' /* state-toggle-lint: allow one-shot action on a running run (not a saved setting) */} )} diff --git a/src/components/stateToggles.test.tsx b/src/components/stateToggles.test.tsx new file mode 100644 index 00000000..41d84171 --- /dev/null +++ b/src/components/stateToggles.test.tsx @@ -0,0 +1,122 @@ +// On/off settings are kit switches labelled by the feature (state-toggles +// rule, 2026-09-30): the switch position IS the state, highlighted when on, +// never a verb label ("Enabled"/"Disabled", "Pause"/"Resume" swaps) and never +// a bare checkbox for a saved node setting or a dialog option. +// +// The package ships no DOM for tests, so the components render with +// react-dom/server and the assertions read the markup. +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { findVerbToggleLabels } from '@abstractframework/ui-kit'; +import { describe, expect, it } from 'vitest'; +import type { Node } from 'reactflow'; + +import { createNodeData, getNodeTemplate } from '../types/nodes'; +import type { FlowNodeData, NodeType } from '../types/flow'; +import { PropertiesPanel } from './PropertiesPanel'; +import { PublishFlowModal } from './PublishFlowModal'; + +const SRC = resolve(__dirname, '..'); + +function nodeOf(type: NodeType, patch: Partial = {}): Node { + const template = getNodeTemplate(type); + if (!template) throw new Error(`no template for ${type}`); + return { id: `n-${type}`, type: 'custom', position: { x: 0, y: 0 }, data: { ...createNodeData(template), ...patch } }; +} + +function renderPanel(node: Node): string { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } }); + return renderToStaticMarkup(createElement(QueryClientProvider, { client }, createElement(PropertiesPanel, { node }))); +} + +/** The
From a72d6c39cc6fae3d7f718e8359eae22397597114 Mon Sep 17 00:00:00 2001 From: Laurent-Philippe Albou <24249870+lpalbou@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:15:38 +0200 Subject: [PATCH 05/14] =?UTF-8?q?space=20on=20phones=20and=20tablets=20(DE?= =?UTF-8?q?SIGN=20=C2=A712,=20=C2=A712.1):=20collapsible=20list=20headers?= =?UTF-8?q?=20in=20the=20flow=20library=20and=20the=20run=20window=20(open?= =?UTF-8?q?=20by=20default,=20remembered,=20Enter/Space),=20one=20scroll?= =?UTF-8?q?=20per=20sheet=20with=20flat=20sections=20below=201024=20px=20(?= =?UTF-8?q?library=20side=20by=20side=20in=20phone=20landscape),=20full-wi?= =?UTF-8?q?dth=20run=20window/assistant/properties=20on=20phones=20(rail?= =?UTF-8?q?=20as=20a=20top=20tab=20row),=20touch=20type=20scale=2014-15=20?= =?UTF-8?q?px=20body=20(canvas=20nodes=20unchanged),=20toolbar=20dialogs?= =?UTF-8?q?=20portaled=20out=20of=20the=20page=20header;=20space.test.ts?= =?UTF-8?q?=20(20=20mutations=20red),=20screens=20module:=20run-detail/run?= =?UTF-8?q?-history/resources=20screens=20with=20a=20seeded=20fixture=20ru?= =?UTF-8?q?n,=20canvas+toasts=20spaceIgnore;=20CHANGELOG=20[Unreleased]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 29 ++ src/components/FlowLibraryModal.tsx | 25 +- src/components/ListDisclosure.tsx | 37 +++ src/components/RunFlowModal.tsx | 25 +- src/components/Toolbar.tsx | 326 ++++++++++---------- src/main.tsx | 4 + src/styles/responsive.test.ts | 4 +- src/styles/space.css | 441 ++++++++++++++++++++++++++++ src/styles/space.test.ts | 232 +++++++++++++++ src/utils/listOpen.ts | 67 +++++ test/responsive.screens.mjs | 122 +++++++- 11 files changed, 1139 insertions(+), 173 deletions(-) create mode 100644 src/components/ListDisclosure.tsx create mode 100644 src/styles/space.css create mode 100644 src/styles/space.test.ts create mode 100644 src/utils/listOpen.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cf9e07db..db72e67d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +Phones and tablets get more of the screen for reading. Desktop windows +1024 px or wider with a mouse look the same as in 0.5.0. Nothing changes in +VisualFlow documents, the server, or the Gateway contract. + +### Added +- **Collapsible lists.** In the flow library and the run window, the list + above the detail has a header you tap to hide or show it (Flows, + Execution), with a mouse, a tap, or Enter and Space. Lists start open. The + editor remembers your choice in this browser. Hiding the list gives the preview or the step details the whole + sheet. + +### Changed +- **One scroll per sheet.** On phones and tablets, the flow library, the run + window and the run history scroll as one page. Lists no longer scroll inside + their own box, and sections are flat, with a thin line between items. +- **Full width on phones.** The run window's text uses the width of the + screen: a 12 px margin, label and value on one line when they fit, JSON and + paths without a box. The assistant's replies are plain text across the + drawer. The Assistant, Properties and Functions tabs sit in a row at the + top, so the canvas and the open panel lose no side column. In the flow + library, a flow's name starts at the margin and the expand arrow sits at + the end of its row. +- **Larger text on touch screens.** Phones and tablets use a larger type + scale: body text 14 to 15 px, secondary text 13 px. Your font-size setting + still applies. Text inside canvas nodes keeps its size and grows when you + zoom. +- Dialogs opened from the toolbar are no longer inside the page header, so + screen readers no longer read them as part of the header. + ## [0.5.0] - 2026-09-30 The editor works on phones, tablets, and browser windows of any size. On a diff --git a/src/components/FlowLibraryModal.tsx b/src/components/FlowLibraryModal.tsx index 6e809077..ea4dab57 100644 --- a/src/components/FlowLibraryModal.tsx +++ b/src/components/FlowLibraryModal.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { AfChip } from '@abstractframework/ui-kit'; +import { AfChip, useAfMedia } from '@abstractframework/ui-kit'; import type { AutomationDefaults, VisualFlow } from '../types/flow'; import type { GatewayContracts } from '../utils/gatewayClient'; import { describeAutomationDefaults } from '../utils/triggerBindings'; @@ -20,6 +20,8 @@ import { type LibraryViewMode, } from '../utils/flowLibraryRows'; import { FlowLibraryList } from './FlowLibraryList'; +import { ListDisclosure } from './ListDisclosure'; +import { LIBRARY_STACKED_QUERY, useListOpen } from '../utils/listOpen'; export interface FlowLibraryModalProps { isOpen: boolean; @@ -116,6 +118,12 @@ export function FlowLibraryModal({ }: FlowLibraryModalProps) { const searchRef = useRef(null); const listRef = useRef(null); + // Phones and tablets stack the list above the preview (space.css): its + // header is a disclosure (open by default, remembered) so collapsing the + // list gives the preview the whole sheet. Side by side on desktop and in + // phone landscape, nothing to collapse. + const stacked = useAfMedia(LIBRARY_STACKED_QUERY); + const [listOpen, toggleListOpen] = useListOpen('library'); const [query, setQuery] = useState(''); const [sortMode, setSortMode] = useState('recent'); @@ -333,6 +341,9 @@ export function FlowLibraryModal({ // onSelect keeps modal state in sync, so skipping here is lossless. const target = e.target as HTMLElement | null; if (target && typeof target.closest === 'function' && target.closest('.af-disclosure')) return; + // The list's own disclosure header is a button: Enter/Space toggle the + // list there (Enter must not load the selected flow). + if (target && typeof target.closest === 'function' && target.closest('.list-disclosure')) return; // Anchor on the selected INSTANCE when known; a STALE row key (its // parent collapsed) falls back to the flow's first visible row rather @@ -623,7 +634,17 @@ export function FlowLibraryModal({
-
+ {stacked ? ( + + ) : null} +