From b29555ee284244d1e521a2b59545fd53172f2849 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Fri, 15 May 2026 15:32:04 +0000 Subject: [PATCH] batch3 T6: usePanes hook + Shiki integration in CodeBlock - hooks/usePanes: per-session panes CRUD; debounced (300ms) state PATCH; immediate position-change PATCH with refresh - CodeBlock: shiki async highlighting via codeToHtml + github-dark theme; LANG_MAP for ts/tsx/js/jsx/py/go/rs/rb/java/c/cpp/cs/php/sh/yaml/json/ toml/md/sql/dockerfile/html/css; falls back to plain pre on unknown lang or async failure - package.json: + shiki Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/package.json | 1 + apps/web/src/components/CodeBlock.tsx | 91 ++++++++++++++-- apps/web/src/hooks/usePanes.ts | 145 ++++++++++++++++++++++++++ pnpm-lock.yaml | 129 +++++++++++++++++++++++ 4 files changed, 359 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/hooks/usePanes.ts diff --git a/apps/web/package.json b/apps/web/package.json index 19a5c6f..58e5caa 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,7 @@ "react-router-dom": "^6.26.0", "remark-gfm": "^4.0.1", "shadcn": "^4.7.0", + "shiki": "^1.29.2", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0" diff --git a/apps/web/src/components/CodeBlock.tsx b/apps/web/src/components/CodeBlock.tsx index 99b0256..21d0078 100644 --- a/apps/web/src/components/CodeBlock.tsx +++ b/apps/web/src/components/CodeBlock.tsx @@ -1,16 +1,86 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Check, Copy } from 'lucide-react'; +import { codeToHtml } from 'shiki'; -// NOTE: spec calls for syntax-highlighted code blocks. Highlighting deferred -// to keep dep footprint minimal; this renders styled mono code with a copy -// button. Adding a highlighter (shiki / highlight.js) is a one-import swap. +// NOTE: spec calls for syntax-highlighted code blocks. Added Shiki in v1.1. +// Shiki output is compiler-generated and does not contain user input; setting +// it via a ref is safe here. interface Props { code: string; lang?: string; } +const LANG_MAP: Record = { + ts: 'typescript', + tsx: 'tsx', + typescript: 'typescript', + js: 'javascript', + jsx: 'jsx', + javascript: 'javascript', + py: 'python', + python: 'python', + go: 'go', + rs: 'rust', + rust: 'rust', + rb: 'ruby', + ruby: 'ruby', + java: 'java', + c: 'c', + cpp: 'cpp', + cs: 'csharp', + csharp: 'csharp', + php: 'php', + sh: 'bash', + bash: 'bash', + shell: 'bash', + yaml: 'yaml', + yml: 'yaml', + json: 'json', + toml: 'toml', + md: 'markdown', + markdown: 'markdown', + sql: 'sql', + dockerfile: 'dockerfile', + html: 'html', + css: 'css', +}; + +const SHIKI_THEME = 'github-dark'; + export function CodeBlock({ code, lang }: Props) { const [copied, setCopied] = useState(false); + const [html, setHtml] = useState(null); + const highlightRef = useRef(null); + + useEffect(() => { + let cancelled = false; + const mappedLang = (lang && LANG_MAP[lang.toLowerCase()]) ?? null; + if (!mappedLang) { + setHtml(null); + return; + } + (async () => { + try { + const result = await codeToHtml(code, { lang: mappedLang, theme: SHIKI_THEME }); + if (!cancelled) setHtml(result); + } catch (err) { + console.warn('shiki failed', err); + if (!cancelled) setHtml(null); + } + })(); + return () => { + cancelled = true; + }; + }, [code, lang]); + + // Inject Shiki HTML via ref; output is compiler-generated, not user input. + useEffect(() => { + if (highlightRef.current) { + // Shiki generates sanitized HTML spans — not user-supplied content. + // eslint-disable-next-line no-unsanitized/property + highlightRef.current.innerHTML = html ?? ''; + } + }, [html]); async function copy() { try { @@ -36,9 +106,16 @@ export function CodeBlock({ code, lang }: Props) { {copied ? 'Copied' : 'Copy'} -
-        {code}
-      
+ {html !== null ? ( +
+ ) : ( +
+          {code}
+        
+ )}
); } diff --git a/apps/web/src/hooks/usePanes.ts b/apps/web/src/hooks/usePanes.ts new file mode 100644 index 0000000..e8f2238 --- /dev/null +++ b/apps/web/src/hooks/usePanes.ts @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { api } from '@/api/client'; +import type { Pane, PaneCreateRequest, PaneState, PaneUpdateRequest } from '@/api/types'; + +export function usePanes(sessionId: string | undefined): { + panes: Pane[] | null; + loading: boolean; + error: string | null; + refresh: () => Promise; + create: (body: PaneCreateRequest) => Promise; + update: (id: string, body: PaneUpdateRequest) => Promise; + remove: (id: string) => Promise; +} { + const [panes, setPanes] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Pending debounced state PATCHes: pane id -> latest PaneState + const pendingState = useRef>(new Map()); + const debounceTimer = useRef | null>(null); + + const flushPendingState = useCallback(() => { + if (debounceTimer.current !== null) { + clearTimeout(debounceTimer.current); + debounceTimer.current = null; + } + const snapshot = new Map(pendingState.current); + pendingState.current.clear(); + for (const [id, state] of snapshot) { + // fire-and-forget; caller surface handles errors via hook error state + void api.panes.update(id, { state }).catch((err) => { + setError(err instanceof Error ? err.message : 'pane operation failed'); + }); + } + }, []); + + const refresh = useCallback(async () => { + if (!sessionId) { + setPanes(null); + return; + } + setLoading(true); + try { + const { panes: list } = await api.panes.getForSession(sessionId); + setPanes(list); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'pane operation failed'); + } finally { + setLoading(false); + } + }, [sessionId]); + + // Fetch on mount / sessionId change; preserve previous list while reloading + // (loading=true but panes stays non-null after first fetch to avoid flash) + useEffect(() => { + void refresh(); + }, [refresh]); + + // Flush debounced PATCHes on unmount + useEffect(() => { + return () => { + flushPendingState(); + }; + }, [flushPendingState]); + + const create = useCallback( + async (body: PaneCreateRequest): Promise => { + if (!sessionId) throw new Error('no session'); + const created = await api.panes.create(sessionId, body); + await refresh(); + return created; + }, + [sessionId, refresh] + ); + + const update = useCallback( + async (id: string, body: PaneUpdateRequest): Promise => { + const stateOnly = body.state !== undefined && body.position === undefined; + + if (stateOnly) { + // Optimistic local update + setPanes((prev) => { + if (!prev) return prev; + let changed = false; + const next = prev.map((pane) => { + if (pane.id !== id) return pane; + changed = true; + // Narrow via discriminated union to satisfy TypeScript + if (pane.kind === 'chat') { + return { ...pane, state: body.state as typeof pane.state }; + } + if (pane.kind === 'file_browser') { + return { ...pane, state: body.state as typeof pane.state }; + } + return pane; + }); + return changed ? next : prev; + }); + + // Coalesce: last state wins within debounce window + pendingState.current.set(id, body.state!); + + if (debounceTimer.current !== null) { + clearTimeout(debounceTimer.current); + } + debounceTimer.current = setTimeout(() => { + debounceTimer.current = null; + flushPendingState(); + }, 300); + } else { + // position involved — fire immediately + try { + await api.panes.update(id, body); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : 'pane operation failed'); + throw err; + } + } + }, + [refresh, flushPendingState] + ); + + const remove = useCallback( + async (id: string): Promise => { + // Optimistic remove + const previous = panes; + setPanes((prev) => (prev ? prev.filter((p) => p.id !== id) : prev)); + + try { + await api.panes.remove(id); + await refresh(); + } catch (err) { + // Rollback + setPanes(previous); + setError(err instanceof Error ? err.message : 'pane operation failed'); + throw err; + } + }, + [panes, refresh] + ); + + return { panes, loading, error, refresh, create, update, remove }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2c3ba6..5087b97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: shadcn: specifier: ^4.7.0 version: 4.7.0(@types/node@20.19.41)(typescript@5.9.3) + shiki: + specifier: ^1.29.2 + version: 1.29.2 sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1566,6 +1569,27 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@shikijs/core@1.29.2': + resolution: {integrity: sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ==} + + '@shikijs/engine-javascript@1.29.2': + resolution: {integrity: sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A==} + + '@shikijs/engine-oniguruma@1.29.2': + resolution: {integrity: sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA==} + + '@shikijs/langs@1.29.2': + resolution: {integrity: sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ==} + + '@shikijs/themes@1.29.2': + resolution: {integrity: sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g==} + + '@shikijs/types@1.29.2': + resolution: {integrity: sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sindresorhus/merge-streams@4.0.0': resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} @@ -2059,6 +2083,9 @@ packages: electron-to-chromium@1.5.355: resolution: {integrity: sha512-LUPZhKzZPYSPme1jEYohpkA+ybYCJztr1quAdBd7E7h3+VOBVcKkwwtBJu41nrjawrRzfb8mtMfzWozoaK0ZIQ==} + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2327,6 +2354,9 @@ packages: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} @@ -2343,6 +2373,9 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} @@ -2908,6 +2941,9 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + oniguruma-to-es@2.3.0: + resolution: {integrity: sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==} + open@11.0.0: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} @@ -3129,6 +3165,15 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} + regex-recursion@5.1.1: + resolution: {integrity: sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@5.1.1: + resolution: {integrity: sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -3244,6 +3289,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shiki@1.29.2: + resolution: {integrity: sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg==} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -5015,6 +5063,41 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} + '@shikijs/core@1.29.2': + dependencies: + '@shikijs/engine-javascript': 1.29.2 + '@shikijs/engine-oniguruma': 1.29.2 + '@shikijs/types': 1.29.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@1.29.2': + dependencies: + '@shikijs/types': 1.29.2 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 2.3.0 + + '@shikijs/engine-oniguruma@1.29.2': + dependencies: + '@shikijs/types': 1.29.2 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@1.29.2': + dependencies: + '@shikijs/types': 1.29.2 + + '@shikijs/themes@1.29.2': + dependencies: + '@shikijs/types': 1.29.2 + + '@shikijs/types@1.29.2': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + '@sindresorhus/merge-streams@4.0.0': {} '@tailwindcss/node@4.3.0': @@ -5444,6 +5527,8 @@ snapshots: electron-to-chromium@1.5.355: {} + emoji-regex-xs@1.0.0: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -5802,6 +5887,20 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -5835,6 +5934,8 @@ snapshots: html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} + http-errors@2.0.0: dependencies: depd: 2.0.0 @@ -6528,6 +6629,12 @@ snapshots: dependencies: mimic-function: 5.0.1 + oniguruma-to-es@2.3.0: + dependencies: + emoji-regex-xs: 1.0.0 + regex: 5.1.1 + regex-recursion: 5.1.1 + open@11.0.0: dependencies: default-browser: 5.5.0 @@ -6821,6 +6928,17 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + regex-recursion@5.1.1: + dependencies: + regex: 5.1.1 + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@5.1.1: + dependencies: + regex-utilities: 2.3.0 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -7021,6 +7139,17 @@ snapshots: shebang-regex@3.0.0: {} + shiki@1.29.2: + dependencies: + '@shikijs/core': 1.29.2 + '@shikijs/engine-javascript': 1.29.2 + '@shikijs/engine-oniguruma': 1.29.2 + '@shikijs/langs': 1.29.2 + '@shikijs/themes': 1.29.2 + '@shikijs/types': 1.29.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0