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..f39e01d 100644 --- a/apps/web/src/app/aros-shell.css +++ b/apps/web/src/app/aros-shell.css @@ -148,6 +148,9 @@ } .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; line-height: 1.45; } +.aros-attach-busy { font-size: 12.5px; color: var(--ink-2); 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..020268e 100644 --- a/apps/web/src/aros-ai/ArosChat.tsx +++ b/apps/web/src/aros-ai/ArosChat.tsx @@ -6,6 +6,11 @@ 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 { CatalogNotice } from '../redesign/attach/CatalogNotice'; +import { type Attachment, type AttachError, type CatalogState, attachError, toWire, barcodeLookupQuery, barcodeOutcome } from '../redesign/attach/attachments'; +import { useConnectionSummary } from '../redesign/data'; // --------------------------------------------------------------------------- // Persistence @@ -14,7 +19,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[]; catalog?: CatalogState; upc?: string; } function loadMessages(greeting: string): Message[] { try { @@ -25,7 +30,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 {} } // --------------------------------------------------------------------------- @@ -41,10 +50,14 @@ export function ArosChat() { const { config } = useWhitelabel(); const c = useChatTheme(); const canvas = useCanvas(); + const connections = useConnectionSummary(); const greeting = config.agent.greeting ?? 'What do you need?'; const [messages, setMessages] = useState(() => loadMessages(greeting)); const [input, setInput] = useState(''); + const [pending, setPending] = useState([]); + const [attachErrors, setAttachErrors] = useState([]); + const [attachBusy, setAttachBusy] = useState(false); 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 +120,25 @@ 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, opts: { barcodeUpc?: string } = {}): Promise => { + if ((!text.trim() && atts.length === 0) || sendingRef.current) return false; + // A send fired while a file is still encoding drops the attachment. + if (attachBusy) 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]); + // Draft safety: cleared optimistically, restored in full on every failure + // path below. A dropped attachment means re-photographing the invoice. setInput(''); + setPending([]); + setAttachErrors([]); + const restoreDraft = () => { + setMessages((prev) => prev.filter((m) => m !== userMsg)); + setInput((current) => current || text.trim()); + setPending((current) => (current.length ? current : atts)); + }; try { const res = await fetch(`${ROUTER_URL}/v1/chat`, { @@ -121,7 +146,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, }), }); @@ -129,11 +155,25 @@ export function ArosChat() { if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); const reply = chatReplyText(data); - setMessages((prev) => [...prev, { role: 'agent', content: reply, timestamp: Date.now() }]); + const catalog: CatalogState | undefined = opts.barcodeUpc + ? barcodeOutcome({ connected: connections.total > 0, transportOk: true, replyText: reply }) + : undefined; + setMessages((prev) => [...prev, { role: 'agent', content: reply, timestamp: Date.now(), ...(catalog ? { catalog, upc: opts.barcodeUpc } : {}) }]); // 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() }]); + if (opts.barcodeUpc) { + setMessages((prev) => [...prev, { role: 'agent', content: 'I couldn’t reach your catalog to look that barcode up.', timestamp: Date.now(), catalog: 'catalog-unreachable', upc: opts.barcodeUpc }]); + sendingRef.current = false; + setSending(false); + return false; + } + restoreDraft(); + // Honest failure: never describe an attachment we couldn't actually read. + setAttachErrors([attachError(hasAttachments + ? 'I couldn’t read that attachment right now. I won’t guess what it says. Your message and files are back in the box — press Send to try again.' + : 'Something went wrong. Your message is back in the box — press Send to try again.')]); + return false; } finally { sendingRef.current = false; setSending(false); @@ -141,6 +181,29 @@ export function ArosChat() { return true; }; + // Barcode → catalog. Without a connected store there is nothing to look the + // code up IN, so the honest not-connected state is shown instead of sending a + // query whose only possible answer would be guesswork. + const onBarcode = (upc: string) => { + if (connections.total === 0) { + setMessages((prev) => [...prev, { role: 'agent', content: '', timestamp: Date.now(), catalog: 'not-connected', upc }]); + return; + } + void sendMessage(barcodeLookupQuery(upc), [], { barcodeUpc: upc }); + }; + + const onCatalogAction = (state: Exclude, upc?: string) => { + if (state === 'not-connected') { window.location.href = '/connectors'; return; } + if (state === 'catalog-unreachable') { if (upc) void sendMessage(barcodeLookupQuery(upc), [], { barcodeUpc: upc }); return; } + setInput(`Add UPC ${upc || ''} to my catalog.`.replace(/\s+/g, ' ').trim()); + inputRef.current?.focus(); + }; + + const removeAttachment = (id: string) => { + setPending((prev) => prev.filter((a) => a.id !== id)); + setAttachErrors([]); + }; + const voice = useVoice({ handsFree: voiceConvo, getInput: () => input, @@ -170,6 +233,9 @@ export function ArosChat() { if (!config.features?.agentChat) return null; + // Blocked while encoding too — otherwise Send fires before the attachment is + // in state and the file is dropped from the turn. + const sendBlocked = (!input.trim() && pending.length === 0) || sending || attachBusy; const agentName = config.agent.name; const font = '-apple-system, "SF Pro Text", "SF Pro Display", BlinkMacSystemFont, "Helvetica Neue", "Inter", system-ui, sans-serif'; @@ -314,20 +380,28 @@ export function ArosChat() { )} -
- {isUser ? ( - {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 && (
@@ -359,6 +433,17 @@ export function ArosChat() {
{/* Input */} + {(pending.length > 0 || attachErrors.length > 0 || attachBusy) && ( +
+ {pending.length > 0 && } + {attachBusy &&
Reading your file…
} + {attachErrors.length > 0 && ( +
+ {attachErrors.map((err) =>
{err.text}
)} +
+ )} +
+ )}
+ { setAttachErrors([]); setPending((prev) => [...prev, ...a]); }} + onBarcode={onBarcode} + 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 0f85637..e8eee71 100644 --- a/apps/web/src/redesign/ConciergeChat.tsx +++ b/apps/web/src/redesign/ConciergeChat.tsx @@ -6,6 +6,11 @@ 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 { CatalogNotice } from './attach/CatalogNotice'; +import { type Attachment, type AttachError, type CatalogState, attachError, toWire, barcodeLookupQuery, barcodeOutcome } from './attach/attachments'; +import { EXTERNAL_INTELLIGENCE_REQUEST, shouldInterceptTextOnly } from './chatIntent'; /** Warm ChatPalette pulled from the live design tokens so the shared mib-widget * renderer matches the current (light/dark) theme. */ @@ -21,7 +26,6 @@ export function warmPalette(): ChatPalette { const ROUTER_URL = (import.meta as any).env?.VITE_ROUTER_URL || ''; const API_BASE = (window as any).__AROS_API_URL__ || (window.location.hostname === 'localhost' ? 'http://localhost:5457' : ''); const FLEET_GUIDANCE = `AROS specialist fleet: Ana handles demand, inventory, item movement, fuel demand, hourly margin, item comparisons, and department comparisons; Victor handles shrink, exceptions, payouts, liability, and revenue integrity; Larry handles labor, scheduling, time stamps, employee hours, payroll preparation, approval-gated time-clock corrections, shifts, and customer-account dayparts; Marco handles daily briefings, tender reports, tax breakdowns, hourly sales, report comparisons, store comparisons, and owner reports; Tessa handles supplier intelligence, cost changes, gift cards, promotions, vendor cost watch, and vendor comparisons. External web, news, and weather require a Research & External Intelligence agent with web.search and weather.read capabilities.`; -const EXTERNAL_INTELLIGENCE_REQUEST = /\b(weather|forecast|temperature|news|headlines|search (?:the )?web|browse (?:the )?(?:web|internet)|look up online)\b/i; type ActiveAgent = { name: string; capabilities: string[] }; @@ -53,6 +57,11 @@ 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([]); + // 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'); @@ -83,14 +92,34 @@ 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, opts: { barcodeUpc?: string } = {}) { 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; + // 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([]); + 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) { + // This interceptor reads the CAPTION only. An attachment turn whose caption + // happens to say "news"/"weather" must never be swallowed here: the composer + // is already cleared above, and this branch returns without restoring, so + // the file would be discarded unrecoverably. Attachments always go to the + // router — same rail as the server's hasAttachments() gate in src/server.ts. + if (shouldInterceptTextOnly({ matched: EXTERNAL_INTELLIGENCE_REQUEST.test(q), hasAttachments, capabilityActive: hasExternalIntelligence })) { const active = activeAgents.length ? activeAgents.map(agent => agent.name).join(', ') : 'no specialists reported'; setMessages(prev => [...prev, { from: 'shre', @@ -112,8 +141,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, }), @@ -130,15 +162,60 @@ 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'; - setMessages(prev => [...prev, { from: 'shre', text: `I couldn’t complete that request (${detail}). Try again in a moment.`, meta: 'Shre · Local' }]); + 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. + 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); } } + // 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) { + setMessages(prev => [...prev, { from: 'shre', text: '', meta: 'AROS catalog', catalog: 'not-connected', upc }]); + return; + } + 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 (
{/* First-chat AI disclosure — renders nothing unless TERMS_GATE_ENABLED */} @@ -149,9 +226,15 @@ export function ConciergeChat({ onConnect, onConnectApps, seed, focusOnMount, in
{m.from === 'me' ? 'DR' : mark}
-
- {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} @@ -175,7 +258,22 @@ export function ConciergeChat({ onConnect, onConnectApps, seed, focusOnMount, in {connections.total === 0 && }
+ {pending.length > 0 && } + {attachBusy &&
Reading your file…
} + {attachErrors.length > 0 && ( +
+ {attachErrors.map(err =>
{err.text}
)} +
+ )}
{ e.preventDefault(); send(draft); }}> + { setAttachErrors([]); setPending(prev => [...prev, ...a]); }} + onBarcode={onBarcode} + 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 new file mode 100644 index 0000000..c87d171 --- /dev/null +++ b/apps/web/src/redesign/attach/AttachSheet.tsx @@ -0,0 +1,291 @@ +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'; + +/** 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 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 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: (msgs: string[]) => void; + onBusyChange?: (busy: boolean) => void; + voiceSlot?: ReactNode; + voiceRow?: 'coming-soon' | 'hidden'; + 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); + 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; + + setBusy(true); + const accepted: Attachment[] = []; + const errors: string[] = []; + const running = [...existing]; + 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); + } + if (accepted.length) onAttach(accepted); + if (errors.length) onError(errors); + } + + 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(); + } + + 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 && ( + <> +
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). Surfaces that already ship a mic pass voiceRow="hidden". */} +
+ + )} + + {/* 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); triggerRef.current?.focus(); }} onDetected={(upc) => { setScanning(false); onBarcode(upc); }} /> + + ); +} + +const S: Record = { + 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: 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 }; +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..0cfba71 --- /dev/null +++ b/apps/web/src/redesign/attach/AttachmentThumbs.tsx @@ -0,0 +1,63 @@ +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?: (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)} +
+ )} +
+ {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. + + )} +
+ ))} +
+ ); +} + +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' }, + // 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 }, + 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 new file mode 100644 index 0000000..cb59d75 --- /dev/null +++ b/apps/web/src/redesign/attach/BarcodeScanner.tsx @@ -0,0 +1,234 @@ +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 + * 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 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'); + 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; + // The