From b0138dc2e0e3a241bf3005005475bf330066a2d9 Mon Sep 17 00:00:00 2001 From: Nirav Patel Date: Thu, 23 Jul 2026 02:27:48 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(chat):=20one=20attach=20sheet=20for=20?= =?UTF-8?q?every=20AROS=20composer=20=E2=80=94=20photo,=20file,=20camera,?= =?UTF-8?q?=20barcode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the shared rich-input affordance to all three AROS chat composers (Concierge, floating FAB, /start landing) so an owner can SHOW the assistant an invoice, a price tag, or a product instead of typing it out. One component, three mounts: - AttachSheet ("+" -> Photo · File · Camera · Barcode · Voice) is self-contained (inline styles) so it drops into each composer's own styling system unchanged. - Attachments send as attachments:[{name,type,dataUrl}] alongside the existing text path — purely additive, remove it and today's behavior returns. - Images are client-downscaled (long edge 2000px, JPEG q0.85) before encoding; 10MB/file and 20MB/turn caps are enforced with a graceful human message instead of an oversized upload crashing the composer. - Thumbnails render in the composer (removable) and in the transcript; ChatMsg gains an optional attachments field. Camera and barcode: - Camera uses a capture="environment" input, plus getUserMedia for live scan. - Barcode decodes with the native BarcodeDetector where present and falls back to a BUNDLED zxing-wasm decoder for iPad Safari / Firefox (the wasm is emitted as a local asset — no CDN, works offline). A manual UPC field is always present as the floor, so a denied camera degrades to a working path and never a dead black pane. - UPC validation (UPC-A/EAN-13/EAN-8/GTIN-14 mod-10) is pure and unit-tested. Honest failure, never fabrication: - A failed attachment turn says "I couldn't read that attachment — I won't guess what it contains", never a described image the model never saw. - resolveCatalogState() resolves the three distinct barcode states (not-connected / not-found / catalog-unreachable) and only reports "found" for a real named item. A connected-store scan is sent as an explicit "do not guess" lookup on the real store-data path; with no store connected the not-connected state is surfaced client-side instead of a sample product. Style: functional core (attachments.ts — caps, downscale math, UPC checksum, catalog-state resolution; 17 unit tests) with the browser I/O confined to a thin shell (encode.ts, decode.ts, and the three components). Storage seam: transcript persistence strips heavy base64 dataUrls so it cannot blow the localStorage quota; durable attachment history is left to Shared S (shre-files + retention) behind that seam. Voice is NOT built here — the sheet carries a clearly-marked mount slot and a TODO for the shared voice-everywhere component, and never fakes recording. Journey spec: docs/journeys/chat-attach-rich-input.md (+ README index). Gate: vitest 458/458 green (17 new), apps/web tsc --noEmit clean, production vite build clean with the wasm bundled locally. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/package.json | 3 +- apps/web/src/app/aros-shell.css | 2 + apps/web/src/aros-ai/ArosChat.tsx | 60 ++++-- apps/web/src/pages/start/StartChat.tsx | 47 ++++- apps/web/src/redesign/ConciergeChat.tsx | 53 +++++- apps/web/src/redesign/attach/AttachSheet.tsx | 150 +++++++++++++++ .../src/redesign/attach/AttachmentThumbs.tsx | 44 +++++ .../src/redesign/attach/BarcodeScanner.tsx | 179 ++++++++++++++++++ .../src/redesign/attach/attachments.test.ts | 113 +++++++++++ apps/web/src/redesign/attach/attachments.ts | 158 ++++++++++++++++ apps/web/src/redesign/attach/decode.ts | 100 ++++++++++ apps/web/src/redesign/attach/encode.ts | 54 ++++++ apps/web/src/redesign/shellData.ts | 2 +- docs/journeys/README.md | 1 + docs/journeys/chat-attach-rich-input.md | 68 +++++++ pnpm-lock.yaml | 32 ++++ vitest.config.ts | 3 + 17 files changed, 1040 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/redesign/attach/AttachSheet.tsx create mode 100644 apps/web/src/redesign/attach/AttachmentThumbs.tsx create mode 100644 apps/web/src/redesign/attach/BarcodeScanner.tsx create mode 100644 apps/web/src/redesign/attach/attachments.test.ts create mode 100644 apps/web/src/redesign/attach/attachments.ts create mode 100644 apps/web/src/redesign/attach/decode.ts create mode 100644 apps/web/src/redesign/attach/encode.ts create mode 100644 docs/journeys/chat-attach-rich-input.md diff --git a/apps/web/package.json b/apps/web/package.json index 060c82b..054947d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,7 +17,8 @@ "react-dom": "^19.0.0", "react-markdown": "^9.0.1", "react-router": "^7.0.0", - "remark-gfm": "^4.0.0" + "remark-gfm": "^4.0.0", + "zxing-wasm": "^3.1.2" }, "devDependencies": { "@testing-library/dom": "^10.4.1", diff --git a/apps/web/src/app/aros-shell.css b/apps/web/src/app/aros-shell.css index def2f9d..5e4ef58 100644 --- a/apps/web/src/app/aros-shell.css +++ b/apps/web/src/app/aros-shell.css @@ -148,6 +148,8 @@ } .aros-chip:hover { border-color: var(--accent); color: var(--ink); } .aros-chip__dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); } +.aros-attach-error { font-size: 12.5px; color: #b45309; margin-bottom: 8px; } +@keyframes spin { to { transform: rotate(360deg); } } .aros-inputrow { display: flex; align-items: center; gap: 10px; border: 1px solid var(--line-strong); border-radius: 14px; background: var(--surface); diff --git a/apps/web/src/aros-ai/ArosChat.tsx b/apps/web/src/aros-ai/ArosChat.tsx index d9c8796..efb270d 100644 --- a/apps/web/src/aros-ai/ArosChat.tsx +++ b/apps/web/src/aros-ai/ArosChat.tsx @@ -6,6 +6,9 @@ import { useCanvas } from './CanvasContext'; import { itemsFromMessages } from './canvas'; import { chatReplyText } from '../lib/chatReply'; import { useVoice, cancelSpeech, type VoiceApi } from './voice'; +import { AttachSheet } from '../redesign/attach/AttachSheet'; +import { AttachmentThumbs } from '../redesign/attach/AttachmentThumbs'; +import { type Attachment, toWire, barcodeLookupQuery } from '../redesign/attach/attachments'; // --------------------------------------------------------------------------- // Persistence @@ -14,7 +17,7 @@ import { useVoice, cancelSpeech, type VoiceApi } from './voice'; const STORAGE_KEY = 'aros-chat-messages'; const MAX_STORED = 50; -interface Message { role: 'user' | 'agent'; content: string; timestamp: number; } +interface Message { role: 'user' | 'agent'; content: string; timestamp: number; attachments?: Attachment[]; } function loadMessages(greeting: string): Message[] { try { @@ -25,7 +28,11 @@ function loadMessages(greeting: string): Message[] { } function persistMessages(msgs: Message[]) { - try { localStorage.setItem(STORAGE_KEY, JSON.stringify(msgs.slice(-MAX_STORED))); } catch {} + // Drop heavy base64 dataUrls before persisting — they blow the localStorage + // quota. Durable attachment history is Shared S's job (shre-files + message + // ref); until then a reloaded transcript shows a lightweight file chip. + const light = msgs.slice(-MAX_STORED).map((m) => (m.attachments ? { ...m, attachments: m.attachments.map((a) => ({ ...a, dataUrl: '' })) } : m)); + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(light)); } catch {} } // --------------------------------------------------------------------------- @@ -45,6 +52,8 @@ export function ArosChat() { const greeting = config.agent.greeting ?? 'What do you need?'; const [messages, setMessages] = useState(() => loadMessages(greeting)); const [input, setInput] = useState(''); + const [pending, setPending] = useState([]); + const [attachError, setAttachError] = useState(''); const [open, setOpen] = useState(false); const [sending, setSending] = useState(false); // Voice-conversation mode: hands-free (each spoken utterance auto-sends) + replies read aloud. @@ -107,13 +116,16 @@ export function ArosChat() { useEffect(() => { voiceConvoRef.current = voiceConvo; }, [voiceConvo]); useEffect(() => { openRef.current = open; }, [open]); - const sendMessage = async (text: string): Promise => { - if (!text.trim() || sendingRef.current) return false; + const sendMessage = async (text: string, atts: Attachment[] = pending): Promise => { + if ((!text.trim() && atts.length === 0) || sendingRef.current) return false; sendingRef.current = true; - const userMsg: Message = { role: 'user', content: text.trim(), timestamp: Date.now() }; + const hasAttachments = atts.length > 0; + const userMsg: Message = { role: 'user', content: text.trim(), timestamp: Date.now(), ...(hasAttachments ? { attachments: atts } : {}) }; setSending(true); setMessages((prev) => [...prev, userMsg]); setInput(''); + setPending([]); + setAttachError(''); try { const res = await fetch(`${ROUTER_URL}/v1/chat`, { @@ -121,7 +133,8 @@ export function ArosChat() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ agentId: 'aros-agent', - messages: [{ role: 'user', content: text.trim() }], + messages: [{ role: 'user', content: text.trim() || 'Please review the attached file(s).' }], + ...(hasAttachments ? { attachments: atts.map(toWire) } : {}), stream: false, }), }); @@ -133,7 +146,11 @@ export function ArosChat() { // speak only if voice-conversation is still on and the panel is still open (checked live) if (voiceConvoRef.current && openRef.current) voiceRef.current?.speak(reply); } catch { - setMessages((prev) => [...prev, { role: 'agent', content: 'Something went wrong. Please try again.', timestamp: Date.now() }]); + // Honest failure: never describe an attachment we couldn't actually read. + const msg = hasAttachments + ? 'I couldn’t read that attachment right now. I won’t guess what it says — please try again in a moment.' + : 'Something went wrong. Please try again.'; + setMessages((prev) => [...prev, { role: 'agent', content: msg, timestamp: Date.now() }]); } finally { sendingRef.current = false; setSending(false); @@ -141,6 +158,8 @@ export function ArosChat() { return true; }; + const onBarcode = (upc: string) => { void sendMessage(barcodeLookupQuery(upc), []); }; + const voice = useVoice({ handsFree: voiceConvo, getInput: () => input, @@ -320,7 +339,10 @@ export function ArosChat() { border: `1px solid ${isUser ? c.accentSoft : c.border2}`, }}> {isUser ? ( - {msg.content} + <> + {msg.attachments && msg.attachments.length > 0 && } + {msg.content && {msg.content}} + ) : ( {/* Input */} + {(pending.length > 0 || attachError) && ( +
+ {pending.length > 0 && setPending((prev) => prev.filter((_, idx) => idx !== i))} />} + {attachError &&
{attachError}
} +
+ )}
+ { setAttachError(''); setPending((prev) => [...prev, ...a]); }} + onBarcode={onBarcode} + onError={setAttachError} + disabled={sending} + accent={c.accent} + /> {voice.supported && ( )} - diff --git a/apps/web/src/redesign/ConciergeChat.tsx b/apps/web/src/redesign/ConciergeChat.tsx index 0f85637..25d7e5f 100644 --- a/apps/web/src/redesign/ConciergeChat.tsx +++ b/apps/web/src/redesign/ConciergeChat.tsx @@ -6,6 +6,9 @@ import { branding } from './branding'; import { ChatMessageRenderer, type ChatPalette } from '../aros-ai/ChatMessageRenderer'; import { itemsFromMessages, type CanvasWidgetItem } from '../aros-ai/canvas'; import { AiDisclosureModal, AiDisclosureNotice, useAiDisclosure } from '../components/AiDisclosure'; +import { AttachSheet } from './attach/AttachSheet'; +import { AttachmentThumbs } from './attach/AttachmentThumbs'; +import { type Attachment, toWire, barcodeLookupQuery, CATALOG_STATE_COPY } from './attach/attachments'; /** Warm ChatPalette pulled from the live design tokens so the shared mib-widget * renderer matches the current (light/dark) theme. */ @@ -53,6 +56,8 @@ export function ConciergeChat({ onConnect, onConnectApps, seed, focusOnMount, in const palette = warmPalette(); const [messages, setMessages] = useState(initial && initial.length ? initial : demo ? CONCIERGE_SEED : []); const [draft, setDraft] = useState(''); + const [pending, setPending] = useState([]); + const [attachError, setAttachError] = useState(''); const [sending, setSending] = useState(false); const [activeAgents, setActiveAgents] = useState([]); const [activeModel, setActiveModel] = useState('auto'); @@ -83,12 +88,16 @@ export function ConciergeChat({ onConnect, onConnectApps, seed, focusOnMount, in .catch(() => setActiveModel('auto')); }, [demo, session?.access_token, tenant?.id]); - async function send(text: string) { + async function send(text: string, atts: Attachment[] = pending) { const q = text.trim(); - if (!q || sending) return; - const nextMessages: ChatMsg[] = [...messages, { from: 'me', text: q }]; - setMessages(prev => [...prev, { from: 'me', text: q }]); + if ((!q && atts.length === 0) || sending) return; + const hasAttachments = atts.length > 0; + const userMsg: ChatMsg = { from: 'me', text: q, ...(hasAttachments ? { attachments: atts } : {}) }; + const nextMessages: ChatMsg[] = [...messages, userMsg]; + setMessages(prev => [...prev, userMsg]); setDraft(''); + setPending([]); + setAttachError(''); const hasExternalIntelligence = activeAgents.some(agent => agent.capabilities.some(capability => capability === 'weather.read' || capability === 'web.search')); if (EXTERNAL_INTELLIGENCE_REQUEST.test(q) && !hasExternalIntelligence) { const active = activeAgents.length ? activeAgents.map(agent => agent.name).join(', ') : 'no specialists reported'; @@ -112,8 +121,11 @@ export function ConciergeChat({ onConnect, onConnectApps, seed, focusOnMount, in agentId: 'aros-agent', messages: [ { role: 'system', content: `You are the AROS fleet orchestrator. ${FLEET_GUIDANCE} Active agents for this workspace: ${activeAgents.length ? activeAgents.map(agent => agent.name).join(', ') : 'none reported'}. Never expose internal reasoning, tool names, tool errors, or access-control implementation details. If a request needs a capability that is not active, state which agent or capability is unavailable, briefly list the relevant active specialists and what they can do, then direct an owner/admin to Agents to activate an available specialist or Marketplace to add the required agent/fleet. If the required agent is not published, say so clearly. Do not claim an agent is installed when it is not.` }, - ...nextMessages.map(message => ({ role: message.from === 'me' ? 'user' : 'assistant', content: message.text })), + ...nextMessages.map(message => ({ role: message.from === 'me' ? 'user' : 'assistant', content: message.text || (message.attachments?.length ? 'Please review the attached file(s).' : '') })), ], + // Rich-input attachments — the router converts images to a vision block + // and text-extracts documents. Sent as {name,type,dataUrl} per turn. + ...(hasAttachments ? { attachments: atts.map(toWire) } : {}), stream: false, model: activeModel, }), @@ -133,12 +145,29 @@ export function ConciergeChat({ onConnect, onConnectApps, seed, focusOnMount, in setMessages(prev => [...prev, { from: 'shre', text: reply, meta: model ? `${label} · ${model}` : 'Shre · Local', agent, tools }]); } catch (error) { const detail = error instanceof Error ? error.message : 'Unknown chat error'; - setMessages(prev => [...prev, { from: 'shre', text: `I couldn’t complete that request (${detail}). Try again in a moment.`, meta: 'Shre · Local' }]); + // Honest failure: when an attachment was sent but the turn failed, say the + // file couldn't be read — never describe an image we didn't actually see. + const text = hasAttachments + ? `I couldn’t read that attachment right now (${detail}). I won’t guess at what it contains — please try again in a moment.` + : `I couldn’t complete that request (${detail}). Try again in a moment.`; + setMessages(prev => [...prev, { from: 'shre', text, meta: 'Shre · Local' }]); } finally { setSending(false); } } + // Scanned barcode → catalog lookup. With no store connected we surface the + // honest not-connected state instead of guessing; connected stores get an + // explicit "do not invent" lookup query on the real store-data path. + function onBarcode(upc: string) { + if (connections.total === 0) { + const copy = CATALOG_STATE_COPY['not-connected']; + setMessages(prev => [...prev, { from: 'shre', text: `${copy.title} — ${copy.body}`, meta: 'AROS catalog' }]); + return; + } + void send(barcodeLookupQuery(upc), []); + } + return (
{/* First-chat AI disclosure — renders nothing unless TERMS_GATE_ENABLED */} @@ -150,6 +179,7 @@ export function ConciergeChat({ onConnect, onConnectApps, seed, focusOnMount, in
{m.from === 'me' ? 'DR' : mark}
+ {m.attachments && m.attachments.length > 0 && } {m.from === 'me' ? m.text : }
{m.meta && ( @@ -175,7 +205,16 @@ export function ConciergeChat({ onConnect, onConnectApps, seed, focusOnMount, in {connections.total === 0 && }
+ {pending.length > 0 && setPending(prev => prev.filter((_, idx) => idx !== i))} />} + {attachError &&
{attachError}
}
{ e.preventDefault(); send(draft); }}> + { setAttachError(''); setPending(prev => [...prev, ...a]); }} + onBarcode={onBarcode} + onError={setAttachError} + disabled={sending} + /> - + {!messages.some(m => m.from === 'me') && ( diff --git a/apps/web/src/redesign/attach/AttachSheet.tsx b/apps/web/src/redesign/attach/AttachSheet.tsx new file mode 100644 index 0000000..fcea95b --- /dev/null +++ b/apps/web/src/redesign/attach/AttachSheet.tsx @@ -0,0 +1,150 @@ +import { useRef, useState, type ReactNode } from 'react'; +import { Attachment, ATTACH_ACCEPT, checkCap, DOC_MIME, isAllowedType } from './attachments'; +import { encodeAttachment } from './encode'; +import { BarcodeScanner } from './BarcodeScanner'; + +const DOC_ACCEPT = DOC_MIME.join(','); + +/** + * The one "attach" affordance shared by all three AROS composers (Concierge, + * FAB, Start). A single "+" button opens a sheet: Photo · File · Camera · + * Barcode · Voice. Self-contained inline styles so it drops into each + * composer's own styling system unchanged. + * + * - onAttach receives encoded, cap-checked attachments to send. + * - onBarcode receives a decoded/typed UPC (host resolves it in the catalog). + * - onError surfaces graceful over-cap / rejected-type messages. + * - voiceSlot mounts the shared voice-everywhere component (see stm-voice- + * everywhere); when absent the row is clearly marked "coming soon" + * and never fakes recording. + */ +export function AttachSheet({ + existing, + onAttach, + onBarcode, + onError, + voiceSlot, + disabled, + accent = '#3b5bdb', +}: { + existing: Attachment[]; + onAttach: (a: Attachment[]) => void; + onBarcode: (upc: string) => void; + onError: (msg: string) => void; + voiceSlot?: ReactNode; + disabled?: boolean; + accent?: string; +}) { + const [openSheet, setOpenSheet] = useState(false); + const [scanning, setScanning] = useState(false); + const [busy, setBusy] = useState(false); + const photoRef = useRef(null); + const fileRef = useRef(null); + const cameraRef = useRef(null); + + async function ingest(files: FileList | null) { + if (!files || files.length === 0) return; + setBusy(true); + const accepted: Attachment[] = []; + const running = [...existing]; + for (const file of Array.from(files)) { + if (!isAllowedType(file.type)) { + onError(`${file.name || 'That file'} is not a supported type. Attach an image, PDF, or Office/CSV document.`); + continue; + } + try { + const att = await encodeAttachment(file); + const cap = checkCap(running, att.size, att.name); + if (!cap.ok) { onError(cap.reason); continue; } + accepted.push(att); + running.push(att); + } catch { + onError(`Could not read ${file.name || 'that file'}. Try a different file.`); + } + } + setBusy(false); + if (accepted.length) onAttach(accepted); + } + + function pick(ref: React.RefObject) { + setOpenSheet(false); + ref.current?.click(); + } + + const items: { key: string; label: string; icon: ReactNode; onClick?: () => void; node?: ReactNode; muted?: boolean }[] = [ + { key: 'photo', label: 'Photo', icon: , onClick: () => pick(photoRef) }, + { key: 'file', label: 'File', icon: , onClick: () => pick(fileRef) }, + { key: 'camera', label: 'Camera', icon: , onClick: () => pick(cameraRef) }, + { key: 'barcode', label: 'Barcode', icon: , onClick: () => { setOpenSheet(false); setScanning(true); } }, + voiceSlot + ? { key: 'voice', label: 'Voice', icon: , node: voiceSlot } + : { key: 'voice', label: 'Voice · coming soon', icon: , muted: true }, + ]; + + return ( + <> + + + {openSheet && ( + <> +
setOpenSheet(false)} aria-hidden /> +
+ {items.map((it) => ( +
+ {it.icon} + {it.label} + {it.node && {it.node}} +
+ ))} + {/* TODO(voice-everywhere): replace the muted Voice row above with the + shared recorder via the onTranscript/onSend contract (stm-voice-everywhere). */} +
+ + )} + + {/* Hidden inputs — one per source. Camera uses capture=environment. */} + { const f = e.target.files; e.target.value = ''; void ingest(f); }} style={{ display: 'none' }} /> + { const f = e.target.files; e.target.value = ''; void ingest(f); }} style={{ display: 'none' }} /> + { const f = e.target.files; e.target.value = ''; void ingest(f); }} style={{ display: 'none' }} /> + + setScanning(false)} onDetected={(upc) => { setScanning(false); onBarcode(upc); }} /> + + ); +} + +const S: Record = { + trigger: { position: 'relative', width: 32, height: 32, borderRadius: 8, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', border: 'none', cursor: 'pointer' }, + scrim: { position: 'fixed', inset: 0, zIndex: 10000 }, + menu: { position: 'absolute', bottom: 'calc(100% + 8px)', left: 0, zIndex: 10001, minWidth: 200, background: '#fff', borderRadius: 12, border: '1px solid #e5e7eb', boxShadow: '0 12px 40px rgba(0,0,0,0.16)', padding: 6, fontFamily: 'Inter, system-ui, sans-serif' }, + row: { display: 'flex', alignItems: 'center', gap: 10, padding: '9px 10px', borderRadius: 8, cursor: 'pointer', color: '#1a1a2e', fontSize: 13.5 }, + rowMuted: { cursor: 'default' }, + rowIcon: { display: 'flex', width: 18, height: 18, alignItems: 'center', justifyContent: 'center' }, + rowLabel: { fontWeight: 500 }, +}; + +const stroke = { fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const }; +function IconPlus() { return ; } +function IconImage() { return ; } +function IconFile() { return ; } +function IconCamera() { return ; } +function IconBarcode() { return ; } +function IconMic() { return ; } +function IconSpinner() { + return ( + + + + + ); +} diff --git a/apps/web/src/redesign/attach/AttachmentThumbs.tsx b/apps/web/src/redesign/attach/AttachmentThumbs.tsx new file mode 100644 index 0000000..1e73577 --- /dev/null +++ b/apps/web/src/redesign/attach/AttachmentThumbs.tsx @@ -0,0 +1,44 @@ +import { Attachment, formatBytes, isImage } from './attachments'; + +/** + * Renders attachment thumbnails — the pending strip inside a composer (with a + * remove control) and the read-only thumbnails shown in the transcript. Images + * show a real preview; documents show a labeled file chip. + */ +export function AttachmentThumbs({ attachments, onRemove, size = 56 }: { attachments: Attachment[]; onRemove?: (index: number) => void; size?: number }) { + if (!attachments || attachments.length === 0) return null; + return ( +
+ {attachments.map((a, i) => ( +
+ {isImage(a.type) && a.dataUrl ? ( + {a.name} + ) : ( +
+ {docLabel(a)} +
+ )} + {onRemove && ( + + )} +
+ ))} +
+ ); +} + +function docLabel(a: Attachment): string { + const fromName = a.name.includes('.') ? a.name.split('.').pop() : ''; + if (fromName) return fromName.slice(0, 4).toUpperCase(); + const t = a.type.split('/').pop() || 'FILE'; + return t.slice(0, 4).toUpperCase(); +} + +const S: Record = { + strip: { display: 'flex', flexWrap: 'wrap', gap: 8, padding: '6px 0' }, + item: { position: 'relative', borderRadius: 10, overflow: 'hidden', border: '1px solid #e5e7eb', background: '#f5f6fb', flexShrink: 0 }, + img: { width: '100%', height: '100%', objectFit: 'cover' }, + doc: { width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }, + docExt: { fontSize: 10, fontWeight: 800, color: '#6b7280', letterSpacing: 0.5 }, + remove: { position: 'absolute', top: 2, right: 2, width: 18, height: 18, borderRadius: '50%', border: 'none', background: 'rgba(0,0,0,0.6)', color: '#fff', fontSize: 10, lineHeight: '18px', cursor: 'pointer', padding: 0 }, +}; diff --git a/apps/web/src/redesign/attach/BarcodeScanner.tsx b/apps/web/src/redesign/attach/BarcodeScanner.tsx new file mode 100644 index 0000000..ef1fa28 --- /dev/null +++ b/apps/web/src/redesign/attach/BarcodeScanner.tsx @@ -0,0 +1,179 @@ +import { useEffect, useRef, useState } from 'react'; +import { isValidUpc, normalizeUpc } from './attachments'; +import { buildFrameDecoder, decodeWithZxing, Decoder } from './decode'; + +/** + * Barcode scanner sheet. Live camera scan (BarcodeDetector or zxing-wasm), + * a still "take a photo" path for browsers without a live loop, and an + * always-present manual UPC entry floor. Camera-denied degrades to manual + + * photo — never a dead/black pane. Emits the decoded/typed UPC via onDetected. + */ +export function BarcodeScanner({ open, onClose, onDetected }: { open: boolean; onClose: () => void; onDetected: (upc: string) => void }) { + const videoRef = useRef(null); + const canvasRef = useRef(null); + const streamRef = useRef(null); + const decoderRef = useRef(null); + const rafRef = useRef(null); + const [mode, setMode] = useState<'starting' | 'scanning' | 'denied' | 'no-camera'>('starting'); + const [manual, setManual] = useState(''); + const [note, setNote] = useState(''); + + useEffect(() => { + if (!open) return; + let cancelled = false; + setMode('starting'); + setManual(''); + setNote(''); + + (async () => { + decoderRef.current = await buildFrameDecoder(); + if (cancelled) return; + if (!navigator.mediaDevices?.getUserMedia) { + setMode('no-camera'); + return; + } + try { + const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } }, audio: false }); + if (cancelled) { stream.getTracks().forEach((t) => t.stop()); return; } + streamRef.current = stream; + const video = videoRef.current; + if (video) { + video.srcObject = stream; + await video.play().catch(() => {}); + } + setMode('scanning'); + setNote(decoderRef.current ? 'Point the camera at a barcode.' : 'Live scanning is unavailable on this browser — take a photo or type the code.'); + loop(); + } catch (err) { + const name = (err as { name?: string })?.name; + setMode(name === 'NotAllowedError' || name === 'SecurityError' ? 'denied' : 'no-camera'); + } + })(); + + function loop() { + const video = videoRef.current; + const canvas = canvasRef.current; + const decode = decoderRef.current; + if (cancelled || !video || !canvas || !decode || video.readyState < 2) { + if (!cancelled) rafRef.current = requestAnimationFrame(loop); + return; + } + const w = video.videoWidth, h = video.videoHeight; + if (w && h) { + canvas.width = w; canvas.height = h; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.drawImage(video, 0, 0, w, h); + void decode(canvas).then((code) => { + if (cancelled || !code) return; + if (isValidUpc(code)) { finish(code); } + }); + } + } + // Throttle the decode loop to ~4 fps. + rafRef.current = window.setTimeout(() => { rafRef.current = requestAnimationFrame(loop); }, 250) as unknown as number; + } + + function finish(code: string) { + stop(); + onDetected(code); + } + + return () => { cancelled = true; stop(); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + function stop() { + if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); clearTimeout(rafRef.current); rafRef.current = null; } + streamRef.current?.getTracks().forEach((t) => t.stop()); + streamRef.current = null; + const video = videoRef.current; + if (video) video.srcObject = null; + } + + async function onPhoto(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file) return; + setNote('Reading photo…'); + const code = await decodeWithZxing(file); + if (code && isValidUpc(code)) { stop(); onDetected(code); return; } + setNote(code ? 'That code did not look like a valid UPC. Try again or type it below.' : 'No barcode found in that photo. Try again or type the code below.'); + } + + function submitManual(e: React.FormEvent) { + e.preventDefault(); + const code = normalizeUpc(manual); + if (!code) return; + if (!isValidUpc(code)) { setNote('That is not a valid 8/12/13/14-digit UPC/EAN. Check the digits and try again.'); return; } + stop(); + onDetected(code); + } + + if (!open) return null; + + return ( +
+
e.stopPropagation()} style={S.sheet}> +
+ Scan a barcode + +
+ +
+ {mode === 'scanning' ? ( + <> +
+
+ ); +} + +const S: Record = { + overlay: { position: 'fixed', inset: 0, zIndex: 10050, background: 'rgba(0,0,0,0.55)', display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }, + sheet: { width: 'min(440px, 100vw)', background: '#fff', color: '#1a1a2e', borderRadius: '16px 16px 0 0', padding: 16, boxShadow: '0 -8px 40px rgba(0,0,0,0.3)', fontFamily: 'Inter, system-ui, sans-serif', maxHeight: '92vh', overflowY: 'auto' }, + header: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }, + title: { fontSize: 15, fontWeight: 700 }, + close: { width: 30, height: 30, borderRadius: 8, border: 'none', background: '#f1f1f4', cursor: 'pointer', fontSize: 14 }, + stage: { position: 'relative', width: '100%', aspectRatio: '4 / 3', background: '#111', borderRadius: 12, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }, + video: { width: '100%', height: '100%', objectFit: 'cover' }, + reticle: { position: 'absolute', left: '12%', right: '12%', top: '40%', height: '20%', border: '2px solid rgba(255,255,255,0.9)', borderRadius: 10, boxShadow: '0 0 0 100vh rgba(0,0,0,0.15)' }, + placeholder: { color: '#e5e7eb', fontSize: 13, textAlign: 'center', padding: 24, lineHeight: 1.5 }, + note: { marginTop: 10, fontSize: 12.5, color: '#6b5b2e', background: '#fbf6e8', border: '1px solid #efe2bd', borderRadius: 8, padding: '8px 10px' }, + photoBtn: { display: 'block', textAlign: 'center', marginTop: 12, padding: '10px 12px', borderRadius: 10, background: '#f5f6fb', color: '#3b5bdb', border: '1px solid #d6def9', fontWeight: 600, fontSize: 13, cursor: 'pointer' }, + manualRow: { display: 'flex', gap: 8, marginTop: 12 }, + manualInput: { flex: 1, minWidth: 0, padding: '10px 12px', fontSize: 14, borderRadius: 10, border: '1px solid #d1d5db', outline: 'none' }, + manualSend: { padding: '0 16px', borderRadius: 10, border: 'none', background: '#3b5bdb', color: '#fff', fontWeight: 700, fontSize: 13, cursor: 'pointer' }, + hint: { marginTop: 8, fontSize: 11.5, color: '#9ca3af', textAlign: 'center' }, +}; diff --git a/apps/web/src/redesign/attach/attachments.test.ts b/apps/web/src/redesign/attach/attachments.test.ts new file mode 100644 index 0000000..b2a170f --- /dev/null +++ b/apps/web/src/redesign/attach/attachments.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { + Attachment, + MAX_FILE_BYTES, + MAX_TURN_BYTES, + checkCap, + dataUrlBytes, + downscaleDimensions, + isAllowedType, + isImage, + isValidUpc, + normalizeUpc, + resolveCatalogState, + barcodeLookupQuery, + toWire, +} from './attachments'; + +const att = (size: number, name = 'x', type = 'image/png'): Attachment => ({ name, type, dataUrl: 'data:,', size }); + +describe('mime allowlist', () => { + it('accepts images and documents, rejects the rest', () => { + expect(isAllowedType('image/jpeg')).toBe(true); + expect(isAllowedType('application/pdf')).toBe(true); + expect(isImage('image/png')).toBe(true); + expect(isImage('application/pdf')).toBe(false); + expect(isAllowedType('application/x-msdownload')).toBe(false); + expect(isAllowedType('')).toBe(false); + }); +}); + +describe('dataUrlBytes', () => { + it('estimates decoded size from base64 length', () => { + // "hi" -> "aGk=" (4 chars, 1 pad) -> 2 bytes + expect(dataUrlBytes('data:text/plain;base64,aGk=')).toBe(2); + expect(dataUrlBytes('not-a-data-url')).toBe(0); + }); +}); + +describe('checkCap', () => { + it('passes an in-bounds file', () => { + expect(checkCap([], 1000)).toEqual({ ok: true }); + }); + it('rejects an over-10MB file gracefully', () => { + const r = checkCap([], MAX_FILE_BYTES + 1, 'big.png'); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toMatch(/big\.png/); + }); + it('rejects when the 20MB turn cap would be exceeded', () => { + const existing = [att(MAX_TURN_BYTES - 500)]; + const r = checkCap(existing, 1000); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toMatch(/per-message/); + }); +}); + +describe('downscaleDimensions', () => { + it('leaves small images unchanged', () => { + expect(downscaleDimensions(800, 600)).toEqual({ w: 800, h: 600 }); + }); + it('caps the long edge and keeps aspect ratio', () => { + const { w, h } = downscaleDimensions(4000, 2000, 2000); + expect(w).toBe(2000); + expect(h).toBe(1000); + }); + it('handles degenerate input', () => { + expect(downscaleDimensions(0, 0)).toEqual({ w: 0, h: 0 }); + }); +}); + +describe('UPC validation', () => { + it('normalizes to digits', () => { + expect(normalizeUpc(' 0-12345 678905 ')).toBe('012345678905'); + }); + it('accepts valid UPC-A / EAN-13 / EAN-8', () => { + expect(isValidUpc('036000291452')).toBe(true); // UPC-A + expect(isValidUpc('4006381333931')).toBe(true); // EAN-13 + expect(isValidUpc('73513537')).toBe(true); // EAN-8 + }); + it('rejects wrong check digit and bad lengths', () => { + expect(isValidUpc('036000291453')).toBe(false); + expect(isValidUpc('12345')).toBe(false); + expect(isValidUpc('')).toBe(false); + }); +}); + +describe('resolveCatalogState — never fabricates', () => { + it('reports not-connected before anything else', () => { + expect(resolveCatalogState({ connected: false, reachable: true, item: { upc: '1', name: 'X' } })).toBe('not-connected'); + }); + it('reports catalog-unreachable on transport failure', () => { + expect(resolveCatalogState({ connected: true, reachable: false })).toBe('catalog-unreachable'); + }); + it('reports not-found for a reachable-but-empty read', () => { + expect(resolveCatalogState({ connected: true, reachable: true, item: null })).toBe('not-found'); + }); + it('reports found only with a real named item', () => { + expect(resolveCatalogState({ connected: true, reachable: true, item: { upc: '036000291452', name: 'Fireball 750ml' } })).toBe('found'); + }); +}); + +describe('barcodeLookupQuery', () => { + it('embeds the normalized UPC and a do-not-guess instruction', () => { + const q = barcodeLookupQuery(' 0360-00291452 '); + expect(q).toMatch(/036000291452/); + expect(q.toLowerCase()).toMatch(/do not guess|not found/); + }); +}); + +describe('toWire', () => { + it('drops the local-only size field', () => { + expect(toWire(att(1234, 'r.png', 'image/png'))).toEqual({ name: 'r.png', type: 'image/png', dataUrl: 'data:,' }); + }); +}); diff --git a/apps/web/src/redesign/attach/attachments.ts b/apps/web/src/redesign/attach/attachments.ts new file mode 100644 index 0000000..ccfff6c --- /dev/null +++ b/apps/web/src/redesign/attach/attachments.ts @@ -0,0 +1,158 @@ +// Functional core for rich chat attachments (photo · file · camera · barcode). +// Pure, framework-free, DOM-free — unit-tested with plain asserts. The React +// components and browser APIs (FileReader, canvas, camera, BarcodeDetector) +// live in the imperative shell (encode.ts, AttachSheet.tsx, BarcodeScanner.tsx). + +/** Router caps: 10MB per file, 20MB per turn (see mission contract Shared S). */ +export const MAX_FILE_BYTES = 10 * 1024 * 1024; +export const MAX_TURN_BYTES = 20 * 1024 * 1024; +/** Large images are downscaled client-side before base64 encoding. */ +export const IMAGE_MAX_DIM = 2000; + +/** MIME allowlist — images the vision model reads, plus documents the router + * text-extracts (PDF/DOCX/XLSX/PPTX). Anything else is rejected before encode. */ +export const IMAGE_MIME = ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/heic', 'image/heif']; +export const DOC_MIME = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'text/plain', + 'text/csv', +]; +export const ATTACH_ACCEPT = [...IMAGE_MIME, ...DOC_MIME].join(','); + +/** One attachment as sent to the router: { name, type, dataUrl }. `size` is the + * decoded byte count, carried locally for cap math and never sent on the wire. */ +export interface Attachment { + name: string; + type: string; + dataUrl: string; + size: number; +} + +/** The wire shape — mission contract: attachments:[{name,type,dataUrl}]. */ +export function toWire(a: Attachment): { name: string; type: string; dataUrl: string } { + return { name: a.name, type: a.type, dataUrl: a.dataUrl }; +} + +export function isImage(type: string): boolean { + return IMAGE_MIME.includes(type.toLowerCase()); +} + +export function isAllowedType(type: string): boolean { + const t = (type || '').toLowerCase(); + return IMAGE_MIME.includes(t) || DOC_MIME.includes(t); +} + +/** Decoded byte length of a base64 data URL, without allocating the buffer. */ +export function dataUrlBytes(dataUrl: string): number { + const comma = dataUrl.indexOf(','); + if (comma < 0) return 0; + const b64 = dataUrl.slice(comma + 1); + const padding = b64.endsWith('==') ? 2 : b64.endsWith('=') ? 1 : 0; + return Math.max(0, Math.floor((b64.length * 3) / 4) - padding); +} + +export function sumBytes(attachments: Attachment[]): number { + return attachments.reduce((n, a) => n + a.size, 0); +} + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +export type CapResult = { ok: true } | { ok: false; reason: string }; + +/** Pure cap gate: honors the 10MB/file and 20MB/turn limits with a graceful, + * human message instead of crashing on an oversized upload. */ +export function checkCap(existing: Attachment[], incomingBytes: number, incomingName = 'That file'): CapResult { + if (incomingBytes > MAX_FILE_BYTES) { + return { ok: false, reason: `${incomingName} is ${formatBytes(incomingBytes)} — over the ${formatBytes(MAX_FILE_BYTES)} per-file limit. Try a smaller file.` }; + } + const total = sumBytes(existing) + incomingBytes; + if (total > MAX_TURN_BYTES) { + return { ok: false, reason: `Adding ${incomingName} would exceed the ${formatBytes(MAX_TURN_BYTES)} per-message limit. Send what you have, then attach the rest.` }; + } + return { ok: true }; +} + +/** Downscale math for large images — keeps aspect ratio, caps the long edge. + * Returns integer dimensions; returns the input unchanged when already small. */ +export function downscaleDimensions(w: number, h: number, maxDim = IMAGE_MAX_DIM): { w: number; h: number } { + if (w <= 0 || h <= 0) return { w: 0, h: 0 }; + const longEdge = Math.max(w, h); + if (longEdge <= maxDim) return { w: Math.round(w), h: Math.round(h) }; + const scale = maxDim / longEdge; + return { w: Math.max(1, Math.round(w * scale)), h: Math.max(1, Math.round(h * scale)) }; +} + +// ── Barcode / UPC ────────────────────────────────────────────────────────── + +/** Keep only digits — scanners and manual entry both feed here. */ +export function normalizeUpc(raw: string): string { + return (raw || '').replace(/\D+/g, ''); +} + +/** UPC-A/EAN-13/EAN-8/GTIN-14 mod-10 check-digit validation (pure). */ +export function isValidUpc(code: string): boolean { + const d = normalizeUpc(code); + if (![8, 12, 13, 14].includes(d.length)) return false; + const digits = d.split('').map(Number); + const check = digits.pop() as number; + // From the rightmost non-check digit, weights alternate 3,1,3,1… + let sum = 0; + for (let i = digits.length - 1, w = 3; i >= 0; i--, w = w === 3 ? 1 : 3) { + sum += digits[i] * w; + } + return (10 - (sum % 10)) % 10 === check; +} + +/** Catalog lookup outcome — the model/backend never invents a product. Every + * branch maps to exactly one honest UI state; a hit requires a real `item`. */ +export type CatalogState = 'not-connected' | 'catalog-unreachable' | 'not-found' | 'found'; + +export interface CatalogItem { + upc: string; + name: string; + price?: number | null; + stock?: number | null; + store?: string | null; +} + +export interface CatalogLookupInput { + /** Is any POS/store connected to this workspace? */ + connected: boolean; + /** Did the catalog read complete without a transport/permission error? */ + reachable: boolean; + /** The matched item, if the catalog returned exactly one. */ + item?: CatalogItem | null; +} + +/** Pure resolver for the three-plus honest barcode states. Order matters: + * a missing store connection is reported before an unreachable catalog, and a + * reachable-but-empty read is "not found" — never a fabricated match. */ +export function resolveCatalogState(input: CatalogLookupInput): CatalogState { + if (!input.connected) return 'not-connected'; + if (!input.reachable) return 'catalog-unreachable'; + if (input.item && input.item.name) return 'found'; + return 'not-found'; +} + +/** The chat query a scanned UPC becomes when a store IS connected. The explicit + * "do not guess" instruction threads the never-fabricate rule to the model, so + * an item the catalog doesn't have comes back as an honest not-found. */ +export function barcodeLookupQuery(upc: string): string { + return `Look up UPC ${normalizeUpc(upc)} in my connected store catalog and report the item name, price, and stock on hand. If this UPC is not in the catalog, say it is not found — do not guess or invent a product.`; +} + +export const CATALOG_STATE_COPY: Record, { title: string; body: string; cta: string }> = { + 'not-connected': { title: 'No store connected', body: 'Link a POS store to look up scanned items in your live catalog.', cta: 'Connect Store' }, + 'catalog-unreachable': { title: 'Catalog unavailable', body: 'The connected store’s catalog could not be reached just now. Try again in a moment.', cta: 'Retry' }, + 'not-found': { title: 'Not in your catalog', body: 'That barcode did not match any item in your connected store. Want to add it?', cta: 'Add item' }, +}; diff --git a/apps/web/src/redesign/attach/decode.ts b/apps/web/src/redesign/attach/decode.ts new file mode 100644 index 0000000..e34d6d1 --- /dev/null +++ b/apps/web/src/redesign/attach/decode.ts @@ -0,0 +1,100 @@ +// Imperative shell for barcode decoding. Two engines, one contract: +// 1. Native BarcodeDetector — Chrome / Android / newer Safari (fast, no JS). +// 2. zxing-wasm (bundled) — iPad Safari / Firefox, which lack (1). +// Both feed the same normalize/validate core (attachments.ts). A manual UPC +// field in BarcodeScanner is the always-present floor if neither engine loads. + +import { normalizeUpc } from './attachments'; + +/** Retail 1D symbologies we care about for UPC/EAN price-tag scanning. */ +const FORMATS = ['ean_13', 'ean_8', 'upc_a', 'upc_e', 'code_128', 'code_39', 'itf', 'codabar']; + +export function hasBarcodeDetector(): boolean { + return typeof (globalThis as any).BarcodeDetector === 'function'; +} + +export async function createNativeDetector(): Promise { + try { + const BD = (globalThis as any).BarcodeDetector; + if (typeof BD !== 'function') return null; + // Only request formats the browser actually supports. + let formats = FORMATS; + if (typeof BD.getSupportedFormats === 'function') { + const supported: string[] = await BD.getSupportedFormats(); + formats = FORMATS.filter((f) => supported.includes(f)); + if (formats.length === 0) return null; + } + return new BD({ formats }); + } catch { + return null; + } +} + +/** Decode from any canvas-drawable source using the native detector. */ +export async function detectNative(detector: any, source: CanvasImageSource | ImageBitmap): Promise { + try { + const codes = await detector.detect(source); + const raw = codes?.[0]?.rawValue; + return raw ? normalizeUpc(String(raw)) : null; + } catch { + return null; + } +} + +let zxingReady: Promise | null = null; + +/** Lazy-load zxing-wasm once, pointing it at the Vite-bundled .wasm asset so no + * network fetch is required (works offline / behind strict CSP). Returns null + * if the module or wasm can't load — the caller degrades to manual entry. */ +async function loadZxing(): Promise { + if (!zxingReady) { + zxingReady = (async () => { + const mod = await import('zxing-wasm/reader'); + try { + const { default: wasmUrl } = await import('zxing-wasm/reader/zxing_reader.wasm?url'); + mod.setZXingModuleOverrides({ locateFile: (path: string, prefix: string) => (path.endsWith('.wasm') ? (wasmUrl as string) : prefix + path) }); + } catch { + /* fall back to the module's default wasm resolution */ + } + return mod; + })(); + } + try { + return await zxingReady; + } catch { + zxingReady = null; + return null; + } +} + +/** Decode a still image (Blob/File or ImageData) via zxing-wasm. */ +export async function decodeWithZxing(source: Blob | ImageData): Promise { + const mod = await loadZxing(); + if (!mod) return null; + try { + const results = await mod.readBarcodes(source, { tryHarder: true } as any); + const raw = results?.find((r: any) => r?.text)?.text; + return raw ? normalizeUpc(String(raw)) : null; + } catch { + return null; + } +} + +export type Decoder = (canvas: HTMLCanvasElement) => Promise; + +/** Build the best available frame decoder for a live
)} -
- {isUser ? ( - <> - {msg.attachments && msg.attachments.length > 0 && } - {msg.content && {msg.content}} - - ) : ( - openWidgetOnCanvas(i, widgetIndex)} - /> +
+ {(msg.content || (msg.attachments && msg.attachments.length > 0)) && ( +
+ {isUser ? ( + <> + {msg.attachments && msg.attachments.length > 0 && } + {msg.content && {msg.content}} + + ) : ( + openWidgetOnCanvas(i, widgetIndex)} + /> + )} +
)} + {msg.catalog && onCatalogAction(state, msg.upc)} />}
{isUser && (
@@ -381,10 +433,15 @@ export function ArosChat() {
{/* Input */} - {(pending.length > 0 || attachError) && ( + {(pending.length > 0 || attachErrors.length > 0 || attachBusy) && (
- {pending.length > 0 && setPending((prev) => prev.filter((_, idx) => idx !== i))} />} - {attachError &&
{attachError}
} + {pending.length > 0 && } + {attachBusy &&
Reading your file…
} + {attachErrors.length > 0 && ( +
+ {attachErrors.map((err) =>
{err.text}
)} +
+ )}
)}
{ setAttachError(''); setPending((prev) => [...prev, ...a]); }} + onAttach={(a) => { setAttachErrors([]); setPending((prev) => [...prev, ...a]); }} onBarcode={onBarcode} - onError={setAttachError} + onError={(msgs) => setAttachErrors(msgs.map(attachError))} + onBusyChange={setAttachBusy} disabled={sending} accent={c.accent} + // This composer ships a WORKING mic two buttons away; a "Voice · + // coming soon" row beside it contradicts what the user can see. + voiceRow={voice.supported ? 'hidden' : 'coming-soon'} /> {voice.supported && ( )} - diff --git a/apps/web/src/redesign/ConciergeChat.tsx b/apps/web/src/redesign/ConciergeChat.tsx index 25d7e5f..56f27c4 100644 --- a/apps/web/src/redesign/ConciergeChat.tsx +++ b/apps/web/src/redesign/ConciergeChat.tsx @@ -8,7 +8,8 @@ import { itemsFromMessages, type CanvasWidgetItem } from '../aros-ai/canvas'; import { AiDisclosureModal, AiDisclosureNotice, useAiDisclosure } from '../components/AiDisclosure'; import { AttachSheet } from './attach/AttachSheet'; import { AttachmentThumbs } from './attach/AttachmentThumbs'; -import { type Attachment, toWire, barcodeLookupQuery, CATALOG_STATE_COPY } from './attach/attachments'; +import { CatalogNotice } from './attach/CatalogNotice'; +import { type Attachment, type AttachError, type CatalogState, attachError, toWire, barcodeLookupQuery, barcodeOutcome } from './attach/attachments'; /** Warm ChatPalette pulled from the live design tokens so the shared mib-widget * renderer matches the current (light/dark) theme. */ @@ -57,7 +58,10 @@ export function ConciergeChat({ onConnect, onConnectApps, seed, focusOnMount, in const [messages, setMessages] = useState(initial && initial.length ? initial : demo ? CONCIERGE_SEED : []); const [draft, setDraft] = useState(''); const [pending, setPending] = useState([]); - const [attachError, setAttachError] = useState(''); + // One entry per rejected file. A single collapsing string meant picking three + // bad files told the user about one of them. + const [attachErrors, setAttachErrors] = useState([]); + const [attachBusy, setAttachBusy] = useState(false); const [sending, setSending] = useState(false); const [activeAgents, setActiveAgents] = useState([]); const [activeModel, setActiveModel] = useState('auto'); @@ -88,16 +92,27 @@ export function ConciergeChat({ onConnect, onConnectApps, seed, focusOnMount, in .catch(() => setActiveModel('auto')); }, [demo, session?.access_token, tenant?.id]); - async function send(text: string, atts: Attachment[] = pending) { + async function send(text: string, atts: Attachment[] = pending, opts: { barcodeUpc?: string } = {}) { const q = text.trim(); if ((!q && atts.length === 0) || sending) return; + // Never send while a file is still being read — the attachment would be + // silently dropped from the turn. + if (attachBusy) return; const hasAttachments = atts.length > 0; const userMsg: ChatMsg = { from: 'me', text: q, ...(hasAttachments ? { attachments: atts } : {}) }; const nextMessages: ChatMsg[] = [...messages, userMsg]; setMessages(prev => [...prev, userMsg]); + // Draft safety: the composer is cleared optimistically for a responsive + // feel, but every failure path below restores BOTH the text and the files. + // Losing an attachment on a failed send means re-photographing the invoice. setDraft(''); setPending([]); - setAttachError(''); + setAttachErrors([]); + const restoreDraft = () => { + setMessages(prev => prev.filter(m => m !== userMsg)); + setDraft(current => current || q); + setPending(current => (current.length ? current : atts)); + }; const hasExternalIntelligence = activeAgents.some(agent => agent.capabilities.some(capability => capability === 'weather.read' || capability === 'web.search')); if (EXTERNAL_INTELLIGENCE_REQUEST.test(q) && !hasExternalIntelligence) { const active = activeAgents.length ? activeAgents.map(agent => agent.name).join(', ') : 'no specialists reported'; @@ -142,15 +157,28 @@ export function ConciergeChat({ onConnect, onConnectApps, seed, focusOnMount, in const tools: string[] = Array.isArray(shre?.toolsUsed) ? shre.toolsUsed.map(String) : []; const label = agent && agent !== 'main' ? agentLabel(agent) : 'Shre'; const model = data?.model || shre?.model; - setMessages(prev => [...prev, { from: 'shre', text: reply, meta: model ? `${label} · ${model}` : 'Shre · Local', agent, tools }]); + // Barcode turns carry their honest catalog outcome: the model's own words + // decide found vs not-found, and the card only ever ADDS a CTA beside + // them — it never renders product data, so it cannot invent a product. + const catalog: CatalogState | undefined = opts.barcodeUpc + ? barcodeOutcome({ connected: connections.total > 0, transportOk: true, replyText: reply }) + : undefined; + setMessages(prev => [...prev, { from: 'shre', text: reply, meta: model ? `${label} · ${model}` : 'Shre · Local', agent, tools, ...(catalog ? { catalog, upc: opts.barcodeUpc } : {}) }]); } catch (error) { const detail = error instanceof Error ? error.message : 'Unknown chat error'; + if (opts.barcodeUpc) { + // A barcode lookup that never reached the catalog is "unreachable" — + // with a Retry, not a shrug. + setMessages(prev => [...prev, { from: 'shre', text: `I couldn’t reach your catalog to look that barcode up (${detail}).`, meta: 'AROS catalog', catalog: 'catalog-unreachable', upc: opts.barcodeUpc }]); + setSending(false); + return; + } + restoreDraft(); // Honest failure: when an attachment was sent but the turn failed, say the // file couldn't be read — never describe an image we didn't actually see. - const text = hasAttachments - ? `I couldn’t read that attachment right now (${detail}). I won’t guess at what it contains — please try again in a moment.` - : `I couldn’t complete that request (${detail}). Try again in a moment.`; - setMessages(prev => [...prev, { from: 'shre', text, meta: 'Shre · Local' }]); + setAttachErrors([attachError(hasAttachments + ? `I couldn’t read that attachment right now (${detail}). I won’t guess at what it contains. Your message and ${atts.length === 1 ? 'file are' : 'files are'} back in the box — press Send to try again.` + : `I couldn’t complete that request (${detail}). Your message is back in the box — press Send to try again.`)]); } finally { setSending(false); } @@ -161,11 +189,26 @@ export function ConciergeChat({ onConnect, onConnectApps, seed, focusOnMount, in // explicit "do not invent" lookup query on the real store-data path. function onBarcode(upc: string) { if (connections.total === 0) { - const copy = CATALOG_STATE_COPY['not-connected']; - setMessages(prev => [...prev, { from: 'shre', text: `${copy.title} — ${copy.body}`, meta: 'AROS catalog' }]); + setMessages(prev => [...prev, { from: 'shre', text: '', meta: 'AROS catalog', catalog: 'not-connected', upc }]); return; } - void send(barcodeLookupQuery(upc), []); + void send(barcodeLookupQuery(upc), [], { barcodeUpc: upc }); + } + + // Every catalog CTA does something real: connect a store, retry the read, or + // put a concrete "add this item" request in the composer. + function onCatalogAction(state: Exclude, upc?: string) { + if (state === 'not-connected') { onConnect?.(); return; } + if (state === 'catalog-unreachable') { if (upc) void send(barcodeLookupQuery(upc), [], { barcodeUpc: upc }); return; } + setDraft(`Add UPC ${upc || ''} to my catalog.`.replace(/\s+/g, ' ').trim()); + inputRef.current?.focus({ preventScroll: true }); + } + + function removeAttachment(id: string) { + // Cap/size errors were computed against the previous set — once a file is + // pulled they are stale, so they go with it. + setPending(prev => prev.filter(a => a.id !== id)); + setAttachErrors([]); } return ( @@ -178,10 +221,15 @@ export function ConciergeChat({ onConnect, onConnectApps, seed, focusOnMount, in
{m.from === 'me' ? 'DR' : mark}
-
- {m.attachments && m.attachments.length > 0 && } - {m.from === 'me' ? m.text : } -
+ {(m.text || (m.attachments && m.attachments.length > 0)) && ( +
+ {/* Success confirmation: what was actually sent stays visible + in the transcript beside the answer. */} + {m.attachments && m.attachments.length > 0 && } + {m.from === 'me' ? m.text : } +
+ )} + {m.catalog && onCatalogAction(state, m.upc)} />} {m.meta && (
{m.meta} @@ -205,14 +253,20 @@ export function ConciergeChat({ onConnect, onConnectApps, seed, focusOnMount, in {connections.total === 0 && }
- {pending.length > 0 && setPending(prev => prev.filter((_, idx) => idx !== i))} />} - {attachError &&
{attachError}
} + {pending.length > 0 && } + {attachBusy &&
Reading your file…
} + {attachErrors.length > 0 && ( +
+ {attachErrors.map(err =>
{err.text}
)} +
+ )}
{ e.preventDefault(); send(draft); }}> { setAttachError(''); setPending(prev => [...prev, ...a]); }} + onAttach={(a) => { setAttachErrors([]); setPending(prev => [...prev, ...a]); }} onBarcode={onBarcode} - onError={setAttachError} + onError={(msgs) => setAttachErrors(msgs.map(attachError))} + onBusyChange={setAttachBusy} disabled={sending} /> - + {!messages.some(m => m.from === 'me') && ( diff --git a/apps/web/src/redesign/attach/AttachSheet.tsx b/apps/web/src/redesign/attach/AttachSheet.tsx index fcea95b..c87d171 100644 --- a/apps/web/src/redesign/attach/AttachSheet.tsx +++ b/apps/web/src/redesign/attach/AttachSheet.tsx @@ -1,37 +1,54 @@ -import { useRef, useState, type ReactNode } from 'react'; -import { Attachment, ATTACH_ACCEPT, checkCap, DOC_MIME, isAllowedType } from './attachments'; -import { encodeAttachment } from './encode'; +import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import { + Attachment, + ATTACH_ACCEPT, + IMAGE_ACCEPT, + checkCap, + isAllowedType, + resolveType, +} from './attachments'; +import { EncodeError, encodeAttachment } from './encode'; import { BarcodeScanner } from './BarcodeScanner'; -const DOC_ACCEPT = DOC_MIME.join(','); +/** Shown when the camera path returns nothing — a denial and a dismissal look + * identical to the page, so the copy offers BOTH recoveries. */ +const CAMERA_EMPTY_MESSAGE = + 'No photo came back from the camera. If your browser blocked camera access, allow it in the address-bar site settings and tap Camera again — or use Photo to pick an existing picture.'; /** * The one "attach" affordance shared by all three AROS composers (Concierge, - * FAB, Start). A single "+" button opens a sheet: Photo · File · Camera · + * FAB, Start). A single "+" button opens a menu: Photo · File · Camera · * Barcode · Voice. Self-contained inline styles so it drops into each * composer's own styling system unchanged. * - * - onAttach receives encoded, cap-checked attachments to send. - * - onBarcode receives a decoded/typed UPC (host resolves it in the catalog). - * - onError surfaces graceful over-cap / rejected-type messages. - * - voiceSlot mounts the shared voice-everywhere component (see stm-voice- - * everywhere); when absent the row is clearly marked "coming soon" - * and never fakes recording. + * - onAttach receives encoded, cap-checked attachments to send. + * - onBarcode receives a decoded/typed UPC (host resolves it in the catalog). + * - onError surfaces one message per rejected file (never a collapsing + * single string — three bad files produce three reasons). + * - onBusyChange lets the host BLOCK SEND while files are still encoding; a + * send fired mid-encode silently drops the attachment. + * - voiceSlot mounts the shared voice-everywhere component. `voiceRow` is + * 'hidden' on surfaces that already show a working mic — a + * "coming soon" row beside a live mic button is a lie. */ export function AttachSheet({ existing, onAttach, onBarcode, onError, + onBusyChange, voiceSlot, + voiceRow = 'coming-soon', disabled, accent = '#3b5bdb', }: { existing: Attachment[]; onAttach: (a: Attachment[]) => void; onBarcode: (upc: string) => void; - onError: (msg: string) => void; + onError: (msgs: string[]) => void; + onBusyChange?: (busy: boolean) => void; voiceSlot?: ReactNode; + voiceRow?: 'coming-soon' | 'hidden'; disabled?: boolean; accent?: string; }) { @@ -41,96 +58,220 @@ export function AttachSheet({ const photoRef = useRef(null); const fileRef = useRef(null); const cameraRef = useRef(null); + const triggerRef = useRef(null); + const menuRef = useRef(null); + const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); + // A `capture=` picker returns an empty FileList on both "cancel" and + // "permission denied", and the browser tells us nothing else — so we detect + // the empty return and offer BOTH recoveries instead of going silent. + const cameraPendingRef = useRef(false); + + useEffect(() => { onBusyChange?.(busy); }, [busy, onBusyChange]); + + // Hosts pass inline callbacks; keep the listener below from re-binding on + // every render. + const onErrorRef = useRef(onError); + onErrorRef.current = onError; + + // A blocked or dismissed camera fires `cancel`, never `change` — without this + // the Camera row was completely silent on denial and the user was left + // tapping a button that appeared to do nothing. + useEffect(() => { + const el = cameraRef.current; + if (!el) return; + const onCancel = () => { + if (!cameraPendingRef.current) return; + cameraPendingRef.current = false; + onErrorRef.current([CAMERA_EMPTY_MESSAGE]); + }; + el.addEventListener('cancel', onCancel); + return () => el.removeEventListener('cancel', onCancel); + }, []); + + async function ingest(files: File[], source: 'photo' | 'file' | 'camera') { + if (source === 'camera') { + const cancelled = cameraPendingRef.current && files.length === 0; + cameraPendingRef.current = false; + if (cancelled) { + onError([CAMERA_EMPTY_MESSAGE]); + return; + } + } + if (files.length === 0) return; - async function ingest(files: FileList | null) { - if (!files || files.length === 0) return; setBusy(true); const accepted: Attachment[] = []; + const errors: string[] = []; const running = [...existing]; - for (const file of Array.from(files)) { - if (!isAllowedType(file.type)) { - onError(`${file.name || 'That file'} is not a supported type. Attach an image, PDF, or Office/CSV document.`); - continue; - } - try { - const att = await encodeAttachment(file); - const cap = checkCap(running, att.size, att.name); - if (!cap.ok) { onError(cap.reason); continue; } - accepted.push(att); - running.push(att); - } catch { - onError(`Could not read ${file.name || 'that file'}. Try a different file.`); + try { + for (const file of files) { + const label = file.name || 'That file'; + const type = resolveType(file.name || '', file.type || ''); + if (!type || !isAllowedType(type)) { + errors.push(`${label} is not a supported type. Attach an image, a PDF, or a modern Office/CSV document (.docx, .xlsx, .pptx, .csv, .txt).`); + continue; + } + // Cap on file.size FIRST. Encoding a 500 MB pick to base64 before + // measuring it balloons the tab into an OOM crash — the cap has to + // reject it while it is still a file handle. + const preCap = checkCap(running, file.size, label); + if (!preCap.ok) { errors.push(preCap.reason); continue; } + try { + const att = await encodeAttachment(file); + // Re-check after encode: downscaling shrinks images, base64 grows + // everything, so the authoritative number is the encoded one. + const cap = checkCap(running, att.size, label); + if (!cap.ok) { errors.push(cap.reason); continue; } + accepted.push(att); + running.push(att); + } catch (err) { + errors.push(err instanceof EncodeError ? err.message : `Could not read ${label}. Try a different file.`); + } } + } finally { + setBusy(false); } - setBusy(false); if (accepted.length) onAttach(accepted); + if (errors.length) onError(errors); } - function pick(ref: React.RefObject) { + const closeSheet = useCallback((restoreFocus = true) => { setOpenSheet(false); + if (restoreFocus) triggerRef.current?.focus(); + }, []); + + function pick(ref: React.RefObject, isCamera = false) { + setOpenSheet(false); + if (isCamera) cameraPendingRef.current = true; ref.current?.click(); } - const items: { key: string; label: string; icon: ReactNode; onClick?: () => void; node?: ReactNode; muted?: boolean }[] = [ - { key: 'photo', label: 'Photo', icon: , onClick: () => pick(photoRef) }, - { key: 'file', label: 'File', icon: , onClick: () => pick(fileRef) }, - { key: 'camera', label: 'Camera', icon: , onClick: () => pick(cameraRef) }, - { key: 'barcode', label: 'Barcode', icon: , onClick: () => { setOpenSheet(false); setScanning(true); } }, - voiceSlot - ? { key: 'voice', label: 'Voice', icon: , node: voiceSlot } - : { key: 'voice', label: 'Voice · coming soon', icon: , muted: true }, + type Item = { key: string; label: string; icon: ReactNode; onSelect?: () => void; node?: ReactNode; muted?: boolean }; + const items: Item[] = [ + { key: 'photo', label: 'Photo', icon: , onSelect: () => pick(photoRef) }, + { key: 'file', label: 'File', icon: , onSelect: () => pick(fileRef) }, + { key: 'camera', label: 'Camera', icon: , onSelect: () => pick(cameraRef, true) }, + { key: 'barcode', label: 'Barcode', icon: , onSelect: () => { setOpenSheet(false); setScanning(true); } }, ]; + if (voiceSlot) items.push({ key: 'voice', label: 'Voice', icon: , node: voiceSlot }); + else if (voiceRow === 'coming-soon') items.push({ key: 'voice', label: 'Voice · coming soon', icon: , muted: true }); + + // Move focus into the menu on open and restore it on close (R2). Without + // this a keyboard user tabs into the page behind the menu. + useEffect(() => { + if (!openSheet) return; + const first = itemRefs.current.find(Boolean); + first?.focus(); + }, [openSheet]); + + function onMenuKeyDown(e: React.KeyboardEvent) { + const focusable = itemRefs.current.filter(Boolean) as HTMLButtonElement[]; + if (focusable.length === 0) return; + const current = focusable.indexOf(document.activeElement as HTMLButtonElement); + const move = (next: number) => { e.preventDefault(); focusable[(next + focusable.length) % focusable.length].focus(); }; + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeSheet(); return; } + if (e.key === 'ArrowDown') return move(current + 1); + if (e.key === 'ArrowUp') return move(current - 1); + if (e.key === 'Home') return move(0); + if (e.key === 'End') return move(focusable.length - 1); + if (e.key === 'Tab') { e.preventDefault(); return move(current + (e.shiftKey ? -1 : 1)); } + } return ( - <> + // The menu is positioned against THIS wrapper. Without a positioned + // ancestor `bottom: calc(100% + 8px)` resolves against the nearest + // positioned block (or the viewport), which put the menu offscreen on all + // three composers and made the whole feature unreachable. Same pattern the + // shell already uses for .rsx2-top__menu / .rsx2-top__pop. + + {busy && Reading your file…} + {openSheet && ( <> -
setOpenSheet(false)} aria-hidden /> -
- {items.map((it) => ( -
- {it.icon} - {it.label} - {it.node && {it.node}} -
+
closeSheet()} aria-hidden /> +
+ {items.map((it, i) => ( + it.muted ? ( + + ) : ( + + ) ))} {/* TODO(voice-everywhere): replace the muted Voice row above with the - shared recorder via the onTranscript/onSend contract (stm-voice-everywhere). */} + shared recorder via the onTranscript/onSend contract (stm-voice- + everywhere). Surfaces that already ship a mic pass voiceRow="hidden". */}
)} - {/* Hidden inputs — one per source. Camera uses capture=environment. */} - { const f = e.target.files; e.target.value = ''; void ingest(f); }} style={{ display: 'none' }} /> - { const f = e.target.files; e.target.value = ''; void ingest(f); }} style={{ display: 'none' }} /> - { const f = e.target.files; e.target.value = ''; void ingest(f); }} style={{ display: 'none' }} /> + {/* Hidden inputs — one per source. Camera uses capture=environment. + "File" accepts images too: a user who taps File to attach a screenshot + must not find images greyed out in the picker. + + NOTE the Array.from BEFORE resetting `value`. `input.files` is a live + FileList: clearing `value` empties it, so capturing the FileList and + then resetting handed the ingest loop an empty list and every single + pick silently vanished. Snapshot to a real array first. */} + { const f = Array.from(e.target.files || []); e.target.value = ''; void ingest(f, 'photo'); }} style={{ display: 'none' }} /> + { const f = Array.from(e.target.files || []); e.target.value = ''; void ingest(f, 'file'); }} style={{ display: 'none' }} /> + { const f = Array.from(e.target.files || []); e.target.value = ''; void ingest(f, 'camera'); }} style={{ display: 'none' }} /> - setScanning(false)} onDetected={(upc) => { setScanning(false); onBarcode(upc); }} /> - + { setScanning(false); triggerRef.current?.focus(); }} onDetected={(upc) => { setScanning(false); onBarcode(upc); }} /> + ); } const S: Record = { - trigger: { position: 'relative', width: 32, height: 32, borderRadius: 8, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', border: 'none', cursor: 'pointer' }, + wrap: { position: 'relative', display: 'inline-flex', alignItems: 'center', flexShrink: 0 }, + trigger: { width: 32, height: 32, borderRadius: 8, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', border: 'none', cursor: 'pointer' }, scrim: { position: 'fixed', inset: 0, zIndex: 10000 }, - menu: { position: 'absolute', bottom: 'calc(100% + 8px)', left: 0, zIndex: 10001, minWidth: 200, background: '#fff', borderRadius: 12, border: '1px solid #e5e7eb', boxShadow: '0 12px 40px rgba(0,0,0,0.16)', padding: 6, fontFamily: 'Inter, system-ui, sans-serif' }, - row: { display: 'flex', alignItems: 'center', gap: 10, padding: '9px 10px', borderRadius: 8, cursor: 'pointer', color: '#1a1a2e', fontSize: 13.5 }, + menu: { position: 'absolute', bottom: 'calc(100% + 8px)', left: 0, zIndex: 10001, minWidth: 210, background: '#fff', borderRadius: 12, border: '1px solid #e5e7eb', boxShadow: '0 12px 40px rgba(0,0,0,0.16)', padding: 6, fontFamily: 'Inter, system-ui, sans-serif' }, + row: { display: 'flex', width: '100%', alignItems: 'center', gap: 10, padding: '11px 10px', minHeight: 44, borderRadius: 8, cursor: 'pointer', color: '#1a1a2e', fontSize: 13.5, background: 'transparent', border: 'none', textAlign: 'left', fontFamily: 'inherit' }, rowMuted: { cursor: 'default' }, rowIcon: { display: 'flex', width: 18, height: 18, alignItems: 'center', justifyContent: 'center' }, rowLabel: { fontWeight: 500 }, + srOnly: { position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap' }, }; const stroke = { fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const }; diff --git a/apps/web/src/redesign/attach/AttachmentThumbs.tsx b/apps/web/src/redesign/attach/AttachmentThumbs.tsx index 1e73577..0cfba71 100644 --- a/apps/web/src/redesign/attach/AttachmentThumbs.tsx +++ b/apps/web/src/redesign/attach/AttachmentThumbs.tsx @@ -4,22 +4,37 @@ import { Attachment, formatBytes, isImage } from './attachments'; * Renders attachment thumbnails — the pending strip inside a composer (with a * remove control) and the read-only thumbnails shown in the transcript. Images * show a real preview; documents show a labeled file chip. + * + * `onRemove` takes the attachment's stable `id`, NOT its index. A closed-over + * index goes stale the instant the list shifts, so a double-tap on the ✕ used + * to delete two files (the second click removed whatever slid into that slot). */ -export function AttachmentThumbs({ attachments, onRemove, size = 56 }: { attachments: Attachment[]; onRemove?: (index: number) => void; size?: number }) { +export function AttachmentThumbs({ attachments, onRemove, size = 56 }: { attachments: Attachment[]; onRemove?: (id: string) => void; size?: number }) { if (!attachments || attachments.length === 0) return null; return ( -
+
{attachments.map((a, i) => ( -
- {isImage(a.type) && a.dataUrl ? ( - {a.name} - ) : ( -
- {docLabel(a)} -
- )} +
+
+ {isImage(a.type) && a.dataUrl ? ( + {a.name} + ) : ( +
+ {docLabel(a)} +
+ )} +
{onRemove && ( - + // 44×44 touch target (WCAG 2.5.5) with a small visual badge inside; + // the old control was an 18px dot, unhittable with a thumb. + )}
))} @@ -36,9 +51,13 @@ function docLabel(a: Attachment): string { const S: Record = { strip: { display: 'flex', flexWrap: 'wrap', gap: 8, padding: '6px 0' }, - item: { position: 'relative', borderRadius: 10, overflow: 'hidden', border: '1px solid #e5e7eb', background: '#f5f6fb', flexShrink: 0 }, + // Extra room so the overhanging 44px remove targets don't collide. + stripRemovable: { gap: 16, padding: '14px 0 6px' }, + item: { position: 'relative', borderRadius: 10, border: '1px solid #e5e7eb', background: '#f5f6fb', flexShrink: 0 }, + clip: { width: '100%', height: '100%', borderRadius: 10, overflow: 'hidden' }, img: { width: '100%', height: '100%', objectFit: 'cover' }, doc: { width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }, docExt: { fontSize: 10, fontWeight: 800, color: '#6b7280', letterSpacing: 0.5 }, - remove: { position: 'absolute', top: 2, right: 2, width: 18, height: 18, borderRadius: '50%', border: 'none', background: 'rgba(0,0,0,0.6)', color: '#fff', fontSize: 10, lineHeight: '18px', cursor: 'pointer', padding: 0 }, + removeHit: { position: 'absolute', top: -14, right: -14, width: 44, height: 44, padding: 0, border: 'none', background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }, + removeDot: { width: 22, height: 22, borderRadius: '50%', background: 'rgba(0,0,0,0.72)', color: '#fff', fontSize: 11, lineHeight: '22px', textAlign: 'center', display: 'block', boxShadow: '0 1px 3px rgba(0,0,0,0.3)' }, }; diff --git a/apps/web/src/redesign/attach/BarcodeScanner.tsx b/apps/web/src/redesign/attach/BarcodeScanner.tsx index ef1fa28..cb59d75 100644 --- a/apps/web/src/redesign/attach/BarcodeScanner.tsx +++ b/apps/web/src/redesign/attach/BarcodeScanner.tsx @@ -1,7 +1,12 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { isValidUpc, normalizeUpc } from './attachments'; import { buildFrameDecoder, decodeWithZxing, Decoder } from './decode'; +/** Decode-loop period. Applied on EVERY branch — the "video not ready yet" + * path used to re-arm with requestAnimationFrame, which spun a 60 fps busy + * loop with the camera LED on for as long as the sheet stayed open. */ +const DECODE_INTERVAL_MS = 250; + /** * Barcode scanner sheet. Live camera scan (BarcodeDetector or zxing-wasm), * a still "take a photo" path for browsers without a live loop, and an @@ -13,16 +18,29 @@ export function BarcodeScanner({ open, onClose, onDetected }: { open: boolean; o const canvasRef = useRef(null); const streamRef = useRef(null); const decoderRef = useRef(null); - const rafRef = useRef(null); + const timerRef = useRef | null>(null); + const doneRef = useRef(false); + const sheetRef = useRef(null); + const manualRef = useRef(null); + const restoreFocusRef = useRef(null); const [mode, setMode] = useState<'starting' | 'scanning' | 'denied' | 'no-camera'>('starting'); + const [attempt, setAttempt] = useState(0); const [manual, setManual] = useState(''); const [note, setNote] = useState(''); + const stop = useCallback(() => { + if (timerRef.current != null) { clearTimeout(timerRef.current); timerRef.current = null; } + streamRef.current?.getTracks().forEach((t) => t.stop()); + streamRef.current = null; + const video = videoRef.current; + if (video) { try { video.pause(); } catch { /* already stopped */ } video.srcObject = null; } + }, []); + useEffect(() => { if (!open) return; let cancelled = false; + doneRef.current = false; setMode('starting'); - setManual(''); setNote(''); (async () => { @@ -36,12 +54,15 @@ export function BarcodeScanner({ open, onClose, onDetected }: { open: boolean; o const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } }, audio: false }); if (cancelled) { stream.getTracks().forEach((t) => t.stop()); return; } streamRef.current = stream; + // The