From 67edbd08cbe947b13b106aebcc28c836adc102b3 Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Thu, 13 Aug 2026 02:42:34 -0700 Subject: [PATCH 1/5] =?UTF-8?q?feat(sheets):=20scaffold=20the=20kit=20?= =?UTF-8?q?=E2=80=94=20the=20hosted=20app,=20repointed=20at=20HarnessRoute?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same recipe the slides kit proved: the pages and components ARE the hosted product's, and exactly one file differs — lib/sh.js maps the data layer onto HarnessRouter. A sheet is a session, its content is sheet.json in that session's workspace, chat is POST /responses. No database, no per-product auth (the console already authenticated whoever is looking), no billing. Every route in the data layer was checked against gateway/app.py before it was written. Five slides-kit bugs were the same bug — a call kept from the hosted product to an endpoint the OSS gateway does not implement — so delete goes to /traces/{id}, history to /turns, rename to PATCH /sessions/{id}, and the sheet is read by path (the live workspace) rather than from the file listing (the checkpoint, which only exists once a turn ends). runnableHarnesses() excludes this kit's own Harness at the source of the list. A sheet whose column ran the sheet's own agent would have that agent editing sheet.json while the app drives a run over it — recursion with a file-write race inside it. Not offered rather than validated away. The harness column itself lands next, against a spec still in review. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013DCjqvx3L4VKAPnFaPe7YJ --- kits/sheets/app/index.html | 9 + kits/sheets/app/package.json | 15 + kits/sheets/app/src/app.jsx | 49 ++ kits/sheets/app/src/components/ChatPanel.jsx | 273 +++++++ kits/sheets/app/src/components/Dialog.jsx | 61 ++ kits/sheets/app/src/components/ShMark.jsx | 12 + kits/sheets/app/src/components/Skeleton.jsx | 122 +++ kits/sheets/app/src/components/Thumbs.jsx | 84 +++ kits/sheets/app/src/components/Topbar.jsx | 130 ++++ kits/sheets/app/src/lib/auth.js | 52 ++ kits/sheets/app/src/lib/collab.js | 34 + kits/sheets/app/src/lib/copilot.js | 41 + kits/sheets/app/src/lib/sh.js | 217 ++++++ kits/sheets/app/src/main.jsx | 9 + kits/sheets/app/src/pages/Landing.jsx | 751 +++++++++++++++++++ kits/sheets/app/src/pages/SheetPage.jsx | 262 +++++++ kits/sheets/app/src/styles/app.css | 647 ++++++++++++++++ kits/sheets/app/src/styles/tokens.css | 154 ++++ kits/sheets/app/vite.config.js | 10 + kits/sheets/kit.json | 20 + 20 files changed, 2952 insertions(+) create mode 100644 kits/sheets/app/index.html create mode 100644 kits/sheets/app/package.json create mode 100644 kits/sheets/app/src/app.jsx create mode 100644 kits/sheets/app/src/components/ChatPanel.jsx create mode 100644 kits/sheets/app/src/components/Dialog.jsx create mode 100644 kits/sheets/app/src/components/ShMark.jsx create mode 100644 kits/sheets/app/src/components/Skeleton.jsx create mode 100644 kits/sheets/app/src/components/Thumbs.jsx create mode 100644 kits/sheets/app/src/components/Topbar.jsx create mode 100644 kits/sheets/app/src/lib/auth.js create mode 100644 kits/sheets/app/src/lib/collab.js create mode 100644 kits/sheets/app/src/lib/copilot.js create mode 100644 kits/sheets/app/src/lib/sh.js create mode 100644 kits/sheets/app/src/main.jsx create mode 100644 kits/sheets/app/src/pages/Landing.jsx create mode 100644 kits/sheets/app/src/pages/SheetPage.jsx create mode 100644 kits/sheets/app/src/styles/app.css create mode 100644 kits/sheets/app/src/styles/tokens.css create mode 100644 kits/sheets/app/vite.config.js create mode 100644 kits/sheets/kit.json diff --git a/kits/sheets/app/index.html b/kits/sheets/app/index.html new file mode 100644 index 0000000..64bd196 --- /dev/null +++ b/kits/sheets/app/index.html @@ -0,0 +1,9 @@ + + + + + + Sheets + +
+ diff --git a/kits/sheets/app/package.json b/kits/sheets/app/package.json new file mode 100644 index 0000000..e801fbd --- /dev/null +++ b/kits/sheets/app/package.json @@ -0,0 +1,15 @@ +{ + "name": "harnessrouter-kit-sheets", + "private": true, + "type": "module", + "scripts": { "dev": "vite", "build": "vite build" }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "reifyui": "^0.4.2", + "lucide-react": "^0.469.0", + "react-markdown": "^9.0.1", + "remark-gfm": "^4.0.0" + }, + "devDependencies": { "@vitejs/plugin-react": "^4.3.4", "vite": "^6.0.7" } +} diff --git a/kits/sheets/app/src/app.jsx b/kits/sheets/app/src/app.jsx new file mode 100644 index 0000000..9f67a2d --- /dev/null +++ b/kits/sheets/app/src/app.jsx @@ -0,0 +1,49 @@ +// Hash router + session keeper. +// #/ landing (prompt, templates, my sheets) +// #/login standalone sign in +// #/s/{id} sheet page (optionally ?seed=) +import { useEffect, useState } from 'react'; +import { isAuthed, refreshToken } from './lib/auth'; +import { LandingPage } from './pages/Landing'; +import { LoginPage } from './pages/Login'; +import { SheetPage } from './pages/SheetPage'; + +function parseHash() { + const raw = window.location.hash.replace(/^#\/?/, ''); + const [path, query = ''] = raw.split('?'); + const params = new URLSearchParams(query); + if (path === 'login') return { page: 'login' }; + if (path.startsWith('s/')) { + return { page: 'sheet', id: decodeURIComponent(path.slice(2)), seed: params.get('seed') || '' }; + } + return { page: 'landing' }; +} + +export default function App() { + const [route, setRoute] = useState(parseHash); + + useEffect(() => { + const onHash = () => setRoute(parseHash()); + window.addEventListener('hashchange', onHash); + return () => window.removeEventListener('hashchange', onHash); + }, []); + + // Sliding session: refresh on mount, on tab focus, and every 6 hours, so an + // active user never lapses. A dead token fails closed into #/login. + useEffect(() => { + if (isAuthed()) refreshToken(); + const onFocus = () => { if (isAuthed() && document.visibilityState !== 'hidden') refreshToken(); }; + window.addEventListener('focus', onFocus); + document.addEventListener('visibilitychange', onFocus); + const iv = window.setInterval(() => { if (isAuthed()) refreshToken(); }, 6 * 60 * 60 * 1000); + return () => { + window.removeEventListener('focus', onFocus); + document.removeEventListener('visibilitychange', onFocus); + window.clearInterval(iv); + }; + }, []); + + if (route.page === 'login') return ; + if (route.page === 'sheet') return ; + return ; +} diff --git a/kits/sheets/app/src/components/ChatPanel.jsx b/kits/sheets/app/src/components/ChatPanel.jsx new file mode 100644 index 0000000..f88b90e --- /dev/null +++ b/kits/sheets/app/src/components/ChatPanel.jsx @@ -0,0 +1,273 @@ +// Copilot chat column — the workflow page's full-height right panel. +// Adapted from ContextualGraph's ChatPanel: the conversational surface is the +// shared UI Core package; the broker transport is Sheets's (lib/copilot.js +// streaming with the non-streaming /chat fallback). On mount it replays the +// member's prior conversation over this workflow; the landing prompt's ?seed= +// fires exactly ONCE (stripped from the URL immediately, skipped when history +// already exists). +import { useEffect, useRef, useState } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { ArrowUp, Mic, PanelRight, Plus, Sparkles } from 'lucide-react'; +import { ChatMessages, ChatMessagesSkeleton } from 'reifyui'; +import { Composer } from 'reifyui'; +import { withReasoning, withResult, withStep, withText } from 'reifyui'; +import { chatHistory, sendChat } from '../lib/sh'; +import { streamTurn } from '../lib/copilot'; + +const RETRY_MS = 20000; + +const STATUS_LABELS = { + failed: 'This turn failed. Please try again.', + cancelled: 'Stopped', + incomplete: 'The turn hit its limit. Send a follow up to continue.', +}; + +/** Drop ?seed= from the hash in place so refresh/back/bookmark never resend it. */ +function stripSeedFromHash() { + const h = window.location.hash || ''; + if (!/[?&]seed=/.test(h)) return; + const [path, query = ''] = h.split('?'); + const params = new URLSearchParams(query); + params.delete('seed'); + const rest = params.toString(); + window.history.replaceState(null, '', window.location.pathname + window.location.search + path + (rest ? `?${rest}` : '')); +} + +/** Broker history turns -> the shared component's message shape. */ +function turnsToMessages(turns) { + const m = []; + for (const t of turns || []) { + if (t.user) m.push({ role: 'user', text: t.user }); + const steps = (t.tools || []).map((x) => ({ name: x.name, args: x.arguments, result: x.result })); + const blocks = []; + if (steps.length) blocks.push({ kind: 'tools', reasoning: '', steps }); + if (t.assistant) blocks.push({ kind: 'text', text: t.assistant }); + const st = (t.status === 'failed' || t.status === 'error') ? 'failed' + : t.status === 'cancelled' ? 'cancelled' + : (t.status === 'incomplete' || t.status === 'max_turns' || t.status === 'timeout') ? 'incomplete' + : 'done'; + if (blocks.length || st !== 'done') m.push({ role: 'assistant', blocks, status: st }); + } + return m; +} + +export function ChatColumn({ sheetId, seed, title, copilotBuilding, collapsed, onToggle, onSheetMaybeChanged, width }) { + const [messages, setMessages] = useState([]); + const [draft, setDraft] = useState(''); + const [busy, setBusy] = useState(false); + const [connecting, setConnecting] = useState(false); + const [histLoading, setHistLoading] = useState(true); + const pendingRef = useRef(null); + const bodyRef = useRef(null); + const seededRef = useRef(false); + const streamOkRef = useRef(true); + + useEffect(() => { + bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight }); + }, [messages, connecting]); + + function updateLastAsst(fn) { + setMessages((m) => { + const out = m.slice(); + for (let i = out.length - 1; i >= 0; i--) { + if (out[i].role === 'assistant') { out[i] = fn({ ...out[i] }); break; } + } + return out; + }); + } + + function dropRunningAsst() { + setMessages((m) => (m.length && m[m.length - 1].role === 'assistant' && m[m.length - 1].status === 'running' + ? m.slice(0, -1) : m)); + } + + async function legacyDeliver(text) { + try { + const res = await sendChat(sheetId, text); + if (res.status === 404 || res.status === 503) { + dropRunningAsst(); + pendingRef.current = text; + setConnecting(true); + return; + } + const body = await res.json().catch(() => null); + const reply = (body && (body.reply || body.message)) || (res.ok ? '...' : `Something went wrong (${res.status}). Please try again.`); + updateLastAsst((a) => ({ ...a, blocks: withText(a.blocks, reply), status: res.ok ? 'done' : 'failed' })); + pendingRef.current = null; + setConnecting(false); + onSheetMaybeChanged?.(); + } catch { + dropRunningAsst(); + pendingRef.current = text; + setConnecting(true); + } + } + + async function deliver(text) { + setBusy(true); + setMessages((m) => [...m, { role: 'assistant', blocks: [], status: 'running' }]); + try { + if (!streamOkRef.current) { + await legacyDeliver(text); + return; + } + const out = await streamTurn(sheetId, text, { + onReasoningDelta: (d) => updateLastAsst((a) => ({ ...a, blocks: withReasoning(a.blocks, d) })), + onToolCall: (name, args, callId) => updateLastAsst((a) => ({ ...a, blocks: withStep(a.blocks, { name, args, callId }) })), + onToolResult: (callId, output) => updateLastAsst((a) => ({ ...a, blocks: withResult(a.blocks, callId, output) })), + onTextDelta: (d) => updateLastAsst((a) => ({ ...a, blocks: withText(a.blocks, d) })), + onDone: (status) => updateLastAsst((a) => ({ ...a, status: status === 'completed' ? 'done' : status })), + onError: () => updateLastAsst((a) => ({ + ...a, + blocks: a.blocks.length ? a.blocks : withText([], 'Something went wrong. Please try again.'), + status: 'failed', + })), + }); + if (out.unsupported) { + streamOkRef.current = false; + await legacyDeliver(text); + return; + } + if (out.connecting) { + dropRunningAsst(); + pendingRef.current = text; + setConnecting(true); + return; + } + pendingRef.current = null; + setConnecting(false); + updateLastAsst((a) => (a.status === 'running' ? { ...a, status: 'done' } : a)); + onSheetMaybeChanged?.(); + } catch { + dropRunningAsst(); + pendingRef.current = text; + setConnecting(true); + } finally { + setBusy(false); + } + } + + function send(text) { + const t = text.trim(); + if (!t || busy || histLoading) return; + setMessages((m) => [...m, { role: 'user', text: t }]); + setDraft(''); + deliver(t); + } + + // Mount: strip ?seed FIRST, then replay history; seed fires only when empty. + useEffect(() => { + stripSeedFromHash(); + let dead = false; + chatHistory(sheetId).then(({ turns }) => { + if (dead) return; + const hist = turnsToMessages(turns); + if (hist.length) setMessages(hist); + setHistLoading(false); + if (seed && !seededRef.current && hist.length === 0) { + seededRef.current = true; + setMessages([{ role: 'user', text: seed }]); + deliver(seed); + } + }); + return () => { dead = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sheetId]); + + // While the broker is not live, retry the pending message on an interval. + useEffect(() => { + if (!connecting) return undefined; + const iv = window.setInterval(() => { + if (pendingRef.current && !busy) deliver(pendingRef.current); + }, RETRY_MS); + return () => window.clearInterval(iv); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [connecting, busy]); + + if (collapsed) { + return ( + + ); + } + + const empty = messages.length === 0; + + return ( + + ); +} diff --git a/kits/sheets/app/src/components/Dialog.jsx b/kits/sheets/app/src/components/Dialog.jsx new file mode 100644 index 0000000..4f7f1ac --- /dev/null +++ b/kits/sheets/app/src/components/Dialog.jsx @@ -0,0 +1,61 @@ +// In-app dialogs. House rule: never window.alert / confirm / prompt. +import { useEffect, useRef, useState } from 'react'; + +export function Dialog({ onClose, children, width }) { + useEffect(() => { + function onKey(e) { if (e.key === 'Escape') onClose?.(); } + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + return ( +
{ if (e.target === e.currentTarget) onClose?.(); }}> +
+ {children} +
+
+ ); +} + +export function ConfirmDialog({ title, body, confirmLabel = 'Delete', busy, onConfirm, onClose }) { + return ( + +

{title}

+
{body}
+
+ + +
+
+ ); +} + +export function NameDialog({ title, body, defaultValue = '', confirmLabel = 'Create', busy, onSubmit, onClose }) { + const [value, setValue] = useState(defaultValue); + const ref = useRef(null); + useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []); + const ok = value.trim().length > 0; + return ( + +

{title}

+ {body &&
{body}
} +
{ e.preventDefault(); if (ok && !busy) onSubmit(value.trim()); }}> + setValue(e.target.value)} + placeholder="Workflow name" + maxLength={80} + /> +
+ + +
+
+
+ ); +} diff --git a/kits/sheets/app/src/components/ShMark.jsx b/kits/sheets/app/src/components/ShMark.jsx new file mode 100644 index 0000000..ed8956a --- /dev/null +++ b/kits/sheets/app/src/components/ShMark.jsx @@ -0,0 +1,12 @@ +// The Sheets product mark: a small grid, emerald brand. +export function ShMark({ size = 20 }) { + return ( + + ); +} diff --git a/kits/sheets/app/src/components/Skeleton.jsx b/kits/sheets/app/src/components/Skeleton.jsx new file mode 100644 index 0000000..e841191 --- /dev/null +++ b/kits/sheets/app/src/components/Skeleton.jsx @@ -0,0 +1,122 @@ +/* + * Skeleton — reusable pulsing placeholders shaped like the eventual content so + * data arriving causes NO layout shift. Light theme, subtle opacity pulse on + * var(--line) (matches the studio graph view's skeleton feel). Respects + * prefers-reduced-motion (pulse disabled in CSS). + * + * Base primitive: + * + * Composed helpers (mirror real layouts 1:1): + * — .gcard grid placeholders + * — rows for the .gtable body + * — carousel template cards + */ +import React from 'react'; + +export function Skeleton({ variant = 'line', w, h, radius, className = '', style }) { + const cls = `sk sk-${variant}${className ? ' ' + className : ''}`; + const s = { ...(style || {}) }; + if (w != null) s.width = typeof w === 'number' ? `${w}px` : w; + if (h != null) s.height = typeof h === 'number' ? `${h}px` : h; + if (radius != null) s.borderRadius = typeof radius === 'number' ? `${radius}px` : radius; + return