From 9733eea4e603cb5cc6d9801d893226d42686b93d Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Thu, 13 Aug 2026 19:56:11 -0700 Subject: [PATCH] feat(sheets): attach a file to the copilot, and dictate into it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two affordances the slides kit has that this one shipped without. ATTACHMENTS reuse the mechanism the agent columns already use: the file becomes an `input_file` block on the next turn, and the gateway writes it into the working directory before the agent starts. Deliberately not a workspace PUT, which is how slides does it — that needs a session a brand-new sheet does not have yet, is refused 409 while a turn is running, and cannot carry bytes that are not text. One encoder now serves both callers (lib/attach.js); cell.js had its own copy. The staged files show as chips above the box with their size and a remove button, and the names ride on the sent message so the bubble shows what went with it. They are not replayed from history — the transcript stores the person's text, not the files — so this says "attached in this session" and does not claim anything about past turns. DICTATION is the browser's own SpeechRecognition. There is no speech service here and no reason to add one; Chrome, Edge and Safari ship exactly this feature. Where the API is absent the button is not rendered at all, rather than rendered disabled: slides has a permanently greyed mic labelled "voice input is coming soon", which is a control that does nothing while implying the product has a capability it does not have. Listening is visible because it cannot be heard — the button takes the brand colour and pulses. Also: the kit description was six lines on the Starter Kits card next to Slides' four. Three now. Verified live: attaching a CSV and asking for its contents returns them exactly, and the file is in the session workspace afterwards; the transcript shows the sentence typed, not the base64. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013DCjqvx3L4VKAPnFaPe7YJ --- kits/sheets/app/src/components/ChatPanel.jsx | 96 +++++++++++++++++++- kits/sheets/app/src/lib/attach.js | 58 ++++++++++++ kits/sheets/app/src/lib/cell.js | 21 +---- kits/sheets/app/src/lib/copilot.js | 8 +- kits/sheets/app/src/lib/dictate.js | 56 ++++++++++++ kits/sheets/app/src/styles/app.css | 33 +++++++ kits/sheets/kit.json | 21 ++++- 7 files changed, 264 insertions(+), 29 deletions(-) create mode 100644 kits/sheets/app/src/lib/attach.js create mode 100644 kits/sheets/app/src/lib/dictate.js diff --git a/kits/sheets/app/src/components/ChatPanel.jsx b/kits/sheets/app/src/components/ChatPanel.jsx index 9d4e0f6..cbdaf47 100644 --- a/kits/sheets/app/src/components/ChatPanel.jsx +++ b/kits/sheets/app/src/components/ChatPanel.jsx @@ -5,11 +5,13 @@ import { useEffect, useRef, useState } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; -import { ArrowUp, PanelRight, Sparkles } from 'lucide-react'; +import { ArrowUp, Mic, PanelRight, Paperclip, Sparkles, X } from 'lucide-react'; import { ChatMessages, ChatMessagesSkeleton, Composer, withReasoning, withResult, withStep, withText } from 'reifyui'; import { sessionTurns, turnsToMessages } from 'reifyui/harness'; import { isPending } from '../lib/sh'; import { runCopilotTurn } from '../lib/copilot'; +import { bytesLabel, fileToInput } from '../lib/attach'; +import { createDictation } from '../lib/dictate'; const RETRY_MS = 20000; @@ -40,9 +42,44 @@ export function ChatColumn({ sheetId, seed, title, agentBusy, onSheetMaybeChange const [busy, setBusy] = useState(false); const [connecting, setConnecting] = useState(false); const [histLoading, setHistLoading] = useState(true); + const [staged, setStaged] = useState([]); // files picked, not yet sent + const [attachErr, setAttachErr] = useState(''); + const [listening, setListening] = useState(false); const pendingRef = useRef(null); const bodyRef = useRef(null); const seededRef = useRef(false); + const fileRef = useRef(null); + // Voice input is the browser's own recogniser, and it is null where the browser has none — the + // button is then not rendered at all, rather than rendered disabled. Built once: createDictation + // returns null on an unsupported build, so a ref-with-null-sentinel would rebuild every render. + const [dictation] = useState(() => createDictation()); + + useEffect(() => () => dictation?.stop(), [dictation]); + + async function pickFiles(list) { + setAttachErr(''); + const next = []; + for (const f of list) { + try { + // eslint-disable-next-line no-await-in-loop + next.push(await fileToInput(f)); + } catch (e) { + setAttachErr(e.message); + } + } + if (next.length) setStaged((cur) => [...cur, ...next]); + } + + function toggleDictation() { + if (!dictation) return; + if (listening) { dictation.stop(); return; } + setListening(true); + dictation.start({ + onText: (text) => setDraft((cur) => (cur ? `${cur.replace(/\s+$/, '')} ${text}` : text)), + onEnd: () => setListening(false), + onError: () => setListening(false), + }); + } useEffect(() => { bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight }); @@ -63,7 +100,7 @@ export function ChatColumn({ sheetId, seed, title, agentBusy, onSheetMaybeChange ? m.slice(0, -1) : m)); } - async function deliver(text) { + async function deliver(text, files = []) { setBusy(true); setMessages((m) => [...m, { role: 'assistant', blocks: [], status: 'running' }]); try { @@ -80,7 +117,7 @@ export function ChatColumn({ sheetId, seed, title, agentBusy, onSheetMaybeChange blocks: a.blocks.length ? a.blocks : withText([], 'Something went wrong. Please try again.'), status: 'failed', })), - }); + }, files.map((f) => f.block)); if (out.connecting) { dropRunningAsst(); pendingRef.current = text; @@ -103,9 +140,16 @@ export function ChatColumn({ sheetId, seed, title, agentBusy, onSheetMaybeChange function send(text) { const t = text.trim(); if (!t || busy || histLoading) return; - setMessages((m) => [...m, { role: 'user', text: t }]); + if (listening) { dictation?.stop(); setListening(false); } + const files = staged; + // The names ride on the message so the bubble shows what was sent with it. They are not + // replayed from history — the transcript stores the person's text, not the files — so this + // is what was attached in this session, not a claim about every past turn. + setMessages((m) => [...m, { role: 'user', text: t, files: files.map((f) => f.name) }]); setDraft(''); - deliver(t); + setStaged([]); + setAttachErr(''); + deliver(t, files); } // Mount: strip ?seed FIRST, then replay history; the seed fires only when there is none. @@ -213,6 +257,13 @@ export function ChatColumn({ sheetId, seed, title, agentBusy, onSheetMaybeChange ) : ( (m.files?.length ? ( + + {m.files.map((n) => ( + {n} + ))} + + ) : null)} renderMarkdown={(t) => {t}} workingLabel="Working..." toolLabels={{ workingLabel: 'Working...' }} @@ -235,6 +286,41 @@ export function ChatColumn({ sheetId, seed, title, agentBusy, onSheetMaybeChange rows={2} autoGrow={false} classNames={{ root: 'cmp', input: '', row: 'cmp-row' }} + attachments={(staged.length > 0 || attachErr) && ( +
+ {staged.map((f, i) => ( + + + {f.name} + {bytesLabel(f.size)} + + + ))} + {attachErr && {attachErr}} +
+ )} + accessoriesLeft={( + <> + { pickFiles([...e.target.files]); e.target.value = ''; }} /> + + + )} + accessoriesRight={dictation ? ( + + ) : null} renderSend={() => (