diff --git a/kanban-artifacts/README.md b/kanban-artifacts/README.md new file mode 100644 index 0000000..b76a204 --- /dev/null +++ b/kanban-artifacts/README.md @@ -0,0 +1,47 @@ +# kanban-artifacts + +Dashboard plugin for browsing and previewing output files from Hermes Kanban task workspaces. + +## What it does + +Adds a **Kanban Artifacts** tab to the Hermes dashboard (positioned after the built-in Kanban tab). Users can: + +- Browse boards and tasks from the Kanban system +- Explore artifact files in each task's workspace (`~/.hermes/kanban/workspaces/`) +- Preview text, code, markdown, images, and other common file types inline +- Download raw files with streaming support (50 MB cap) + +## Security + +- All API routes require a valid session token (`X-Hermes-Session-Token` or `Authorization: Bearer`) +- Path traversal is blocked — only `~/.hermes/kanban/workspaces/` and `~/.hermes/kanban/boards/` are accessible +- Files larger than 50 MB cannot be served via `/raw` +- SQL injection is prevented with parameterized queries +- Markdown content is HTML-escaped before rendering to prevent XSS + +## File structure + +``` +kanban-artifacts/dashboard/ + manifest.json — Dashboard plugin manifest (tab, icon, entry point, API file) + plugin_api.py — FastAPI backend (list boards/tasks/files, serve raw files) + index.js — React frontend source + dist/ + index.js — Built frontend bundle + style.css — Styles +``` + +## Installing + +Copy the `kanban-artifacts/` directory to: + +``` +~/.hermes/plugins/kanban-artifacts/dashboard/ +``` + +The plugin is auto-discovered by the Hermes dashboard on next startup. + +## Reference + +- [Hermes Plugin Proposal #8994](https://github.com/NousResearch/hermes-agent/issues/8994) +- Tracking issue: [Proposal: kanban-artifacts #32473](https://github.com/NousResearch/hermes-agent/issues/32473) \ No newline at end of file diff --git a/kanban-artifacts/dashboard/dist/index.js b/kanban-artifacts/dashboard/dist/index.js new file mode 100644 index 0000000..2d03316 --- /dev/null +++ b/kanban-artifacts/dashboard/dist/index.js @@ -0,0 +1 @@ +(()=>{(function(){"use strict";let p=window.__HERMES_PLUGIN_SDK__;if(!p)return;let{React:F}=p,e=F.createElement,{Card:fe,CardContent:ge,Badge:O,Button:M,Input:P,Label:xe,Select:he,SelectOption:be,Separator:ve,Tabs:ye,TabsList:we,TabsTrigger:Ne}=p.components,{useState:c,useEffect:v,useCallback:ke,useMemo:Q,useRef:$e}=p.hooks,{cn:x,timeAgo:Ce}=p.utils,J=p.useI18n||function(){return{t:{kanbanArtifacts:null},locale:"en"}};function k(s,n,o){let r=s&&s.kanbanArtifacts;if(r){let u=n.split(".");for(let i=0;i/g,">").replace(/"/g,""")}function $(s){return s<1024?s+" B":s<1024*1024?(s/1024).toFixed(1)+" KB":s<1024*1024*1024?(s/(1024*1024)).toFixed(1)+" MB":(s/(1024*1024*1024)).toFixed(2)+" GB"}function V(s){return{todo:"bg-gray-400",in_progress:"bg-blue-500",running:"bg-yellow-500",done:"bg-green-500",completed:"bg-green-500",blocked:"bg-red-500"}[s]||"bg-gray-400"}function C(s){let n=s.split(".").pop().toLowerCase();return{md:"\u{1F4DD}",markdown:"\u{1F4DD}",txt:"\u{1F4C4}",log:"\u{1F4C4}",py:"\u{1F40D}",js:"\u{1F7E8}",ts:"\u{1F537}",jsx:"\u269B\uFE0F",tsx:"\u269B\uFE0F",html:"\u{1F310}",css:"\u{1F3A8}",scss:"\u{1F3A8}",json:"\u{1F4CB}",yaml:"\u2699\uFE0F",yml:"\u2699\uFE0F",toml:"\u2699\uFE0F",png:"\u{1F5BC}\uFE0F",jpg:"\u{1F5BC}\uFE0F",jpeg:"\u{1F5BC}\uFE0F",gif:"\u{1F5BC}\uFE0F",webp:"\u{1F5BC}\uFE0F",svg:"\u{1F5BC}\uFE0F",pdf:"\u{1F4D5}",zip:"\u{1F5DC}\uFE0F",tar:"\u{1F5DC}\uFE0F",gz:"\u{1F5DC}\uFE0F",sh:"\u{1F4DF}",bash:"\u{1F4DF}",sql:"\u{1F5C3}\uFE0F",csv:"\u{1F4CA}"}[n]||"\u{1F4CE}"}let Y=new Set(["py","js","ts","jsx","tsx","html","css","scss","sass","json","yaml","yml","toml","xml","sql","sh","bash","zsh","fish","r","lua","pl","rb","go","rs","c","cpp","h","hpp","java","kt","swift","proto","graphql","gql","md","markdown","txt","log","env"]),Z={py:"python",js:"javascript",ts:"typescript",jsx:"javascript",tsx:"typescript",html:"html",css:"css",scss:"scss",json:"json",yaml:"yaml",yml:"yaml",xml:"xml",sql:"sql",sh:"bash",bash:"bash",md:"markdown",txt:"plaintext",r:"r",rb:"ruby",rs:"rust",go:"go",c:"c",cpp:"cpp",h:"c",hpp:"cpp",java:"java",kt:"kotlin",swift:"swift",proto:"protobuf",graphql:"graphql"};function j({className:s}){return e("div",{className:x("animate-spin rounded-full h-4 w-4 border-2 border-current border-t-transparent",s)})}function ee({file:s,active:n,onClick:o}){return e("div",{className:x("flex items-center gap-2 px-3 py-2 cursor-pointer rounded-md text-sm transition-colors",n?"bg-blue-500/20 text-blue-400":"hover:bg-white/5 text-gray-200"),onClick:o},e("span",{className:"text-base"},C(s.name)),e("span",{className:"flex-1 truncate"},s.name),e("span",{className:"text-gray-500 text-xs shrink-0"},$(s.size)))}function te(){let{t:s}=J(),[n,o]=c([]),[r,u]=c([]),[i,T]=c({}),[Me,De]=c({}),[h,B]=c(null),[l,ae]=c(null),[d,b]=c(null),[I,f]=c(null),[re,R]=c(!0),[le,S]=c(!1),[oe,_]=c(!1),[w,ce]=c(""),[D,E]=c(null),[q,A]=c({}),[L,ie]=c({board:280,file:220}),[Se,U]=c(null),g=F.useRef(null),H=t=>{if(!g.current)return;t.preventDefault();let a=g.current.startX,m=g.current.startWidths,N=t.clientX-a,z={...m};g.current.side==="board-file"?z.board=Math.max(160,m.board+N):z.file=Math.max(140,m.file+N),ie(z)},K=()=>{U(null),g.current=null,document.body.style.userSelect="",document.body.style.cursor="",document.removeEventListener("mousemove",H),document.removeEventListener("mouseup",K)},W=(t,a)=>{a.preventDefault(),g.current={startX:a.clientX,startWidths:{...L},side:t},U(t),document.body.style.userSelect="none",document.body.style.cursor="col-resize",document.addEventListener("mousemove",H),document.addEventListener("mouseup",K)};v(()=>{y("/boards").then(t=>{if(o(t),R(!1),t.length>0&&!h){let a=t[0].slug;B(a),A({[a]:!0})}}).catch(()=>R(!1))},[]),v(()=>{if(!h)return;if(Me[h]){u(Me[h]);return;}y(`/boards/${h}/tasks`).then(data=>{De(prev=>({...prev,[h]:data}));u(data)}).catch(()=>u([]))},[h]),v(()=>{if(!l){b(null),f(null);return}if(i[l.id]!==void 0){b(null),f(null);return}S(!0),b(null),f(null);let a=l.workspace_path?`/tasks/${l.id}/files?path=${encodeURIComponent(l.workspace_path)}`:`/tasks/${l.id}/files`;y(a).then(m=>{T(N=>({...N,[l.id]:m})),S(!1)}).catch(()=>{T(m=>({...m,[l.id]:[]})),S(!1)})},[l]),v(()=>{if(!d){f(null),E(null);return}_(!0),E(null),y(`/files?path=${encodeURIComponent(d.path)}`).then(t=>{f(t),_(!1)}).catch(t=>{E(String(t)),_(!1)})},[d]);function ue(t){A(a=>({...a,[t]:!a[t]})),B(t)}function de(t){ae(t),b(null),f(null)}function me(t){b(t)}let X=Q(()=>{if(!w)return r;let t=w.toLowerCase();return r.filter(a=>(a.title||"").toLowerCase().includes(t)||(a.id||"").toLowerCase().includes(t))},[r,w]),G=l?i[l.id]||[]:[],pe=l?e("div",{className:"flex items-center gap-2 text-xs text-gray-400 mt-1"},e(O,{variant:l.status==="done"?"success":"secondary",className:"text-xs"},l.status),e("span",null,l.assignee||"unassigned")):null;return e("div",{className:"flex h-full overflow-hidden"},e("div",{className:"shrink-0 border-r border-white/10 flex flex-col overflow-hidden",style:{width:L.board}},e("div",{className:"p-3 border-b border-white/10"},e("div",{className:"font-semibold text-sm text-gray-200 mb-2"},k(s,"title","Kanban Artifacts")),e(P,{placeholder:k(s,"filter","Filter tasks\u2026"),value:w,onChange:t=>ce(t.target.value),className:"h-8 text-xs"})),e("div",{className:"flex-1 overflow-y-auto p-2"},re?e("div",{className:"flex justify-center py-8"},e(j)):n.length===0?e("p",{className:"text-xs text-gray-500 px-2"},"No boards found"):n.map(t=>e("div",{key:t.slug,className:"mb-1"},e("div",{className:x("flex items-center gap-1.5 px-2 py-1.5 rounded text-xs font-semibold cursor-pointer","text-gray-400 hover:text-gray-200 select-none transition-colors",h===t.slug&&"text-gray-200"),onClick:()=>ue(t.slug)},e("span",{className:"text-gray-500 transition-transform",style:{transform:q[t.slug]?"rotate(90deg)":"none"}},"\u25B6"),e("span",null,t.slug)),q[t.slug]&&e("div",{className:"mt-1"},(Me[t.slug]||[]).filter(a=>(a.title||"").toLowerCase().includes(w)||(a.id||"").toLowerCase().includes(w)).length===0?e("p",{className:"text-xs text-gray-600 px-2 py-1"},"No tasks"):(Me[t.slug]||[]).filter(a=>(a.title||"").toLowerCase().includes(w)||(a.id||"").toLowerCase().includes(w)).map(a=>e("div",{key:a.id,className:x("flex items-start gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors","hover:bg-white/5",l&&l.id===a.id&&"bg-blue-500/15"),onClick:()=>de(a)},e("span",{className:x("w-2 h-2 rounded-full mt-0.5 shrink-0",V(a.status))}),e("div",{className:"flex-1 min-w-0"},e("div",{className:"text-xs text-gray-300 truncate"},a.title||"(untitled)"),e("div",{className:"text-gray-600 text-xs truncate"},a.id))))))))),e("div",{className:"shrink-0 flex flex-col items-center justify-center cursor-col-resize group relative",style:{width:6},onMouseDown:t=>W("board-file",t),onClick:t=>t.preventDefault()},e("div",{className:"w-0.5 h-full bg-white/10 group-hover:bg-blue-400/60 transition-colors"}),e("div",{className:"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-6 rounded-full bg-white/10 group-hover:bg-blue-400/60 transition-colors hidden group-hover:flex items-center justify-center",style:{pointerEvents:"none"}},e("div",{className:"w-0.5 h-3 rounded-full bg-black/30"}))),e("div",{className:"shrink-0 border-r border-white/10 flex flex-col overflow-hidden",style:{width:L.file}},e("div",{className:"p-3 border-b border-white/10"},l?e("div",null,e("div",{className:"text-sm font-medium text-gray-200 truncate"},l.title||"(untitled)"),pe):e("div",{className:"text-xs text-gray-500"},k(s,"selectTask","Select a task"))),e("div",{className:"flex-1 overflow-y-auto p-1"},l?le?e("div",{className:"flex justify-center py-8"},e(j)):G.length===0?e("p",{className:"text-xs text-gray-600 text-center py-8"},"No files"):G.map(t=>e(ee,{key:t.path,file:t,active:d&&d.path===t.path,onClick:()=>me(t)})):e("p",{className:"text-xs text-gray-600 text-center py-8"},"\u2190 Select a task"))),e("div",{className:"shrink-0 flex flex-col items-center justify-center cursor-col-resize group relative",style:{width:6},onMouseDown:t=>W("file-preview",t),onClick:t=>t.preventDefault()},e("div",{className:"w-0.5 h-full bg-white/10 group-hover:bg-blue-400/60 transition-colors"}),e("div",{className:"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-6 rounded-full bg-white/10 group-hover:bg-blue-400/60 transition-colors hidden group-hover:flex items-center justify-center",style:{pointerEvents:"none"}},e("div",{className:"w-0.5 h-3 rounded-full bg-black/30"}))),e("div",{className:"flex-1 flex flex-col overflow-hidden"},d&&e("div",{className:"flex items-center justify-between px-4 py-2 border-b border-white/10 bg-black/20"},e("div",{className:"flex items-center gap-2 min-w-0"},e("span",{className:"text-sm"},C(d.name)),e("span",{className:"text-sm text-gray-200 truncate"},d.name)),e("div",{className:"flex gap-2 shrink-0"},e(M,{variant:"ghost",size:"sm",className:"text-xs h-7",onClick:()=>{let t=document.createElement("a");t.href=`/api/plugins/kanban-artifacts/raw?path=${encodeURIComponent(d.path)}`,t.download=d.name,t.click()}},"\u2B07 Download"))),e("div",{className:"flex-1 overflow-y-auto"},d?oe?e("div",{className:"flex justify-center items-center h-full"},e(j)):D?e("div",{className:"text-red-400 text-sm p-4"},"Error: "+D):I&&se(I,d):e("div",{className:"flex items-center justify-center h-full text-gray-500 text-sm"},"\u2190 Select a file to preview"))))}function se(s,n){let o=n.name.split(".").pop().toLowerCase(),r=["md","markdown"].includes(o),u=Y.has(o);if(s.mime&&s.mime.startsWith("image/"))return e("div",{className:"p-4 flex justify-center"},e("img",{src:`/api/plugins/kanban-artifacts/raw?path=${encodeURIComponent(n.path)}`,alt:n.name,className:"max-w-full rounded-lg border border-white/10"}));if(s.text!==void 0&&s.text!==null){let i=s.truncated?e("div",{className:"text-yellow-500 text-xs px-4 pt-3"},`\u26A0 File truncated (${$(s.size)}). Download to view full content.`):null;return r?e("div",{className:"p-4 max-w-3xl"},e("div",{className:"prose prose-invert prose-sm max-w-none",dangerouslySetInnerHTML:{__html:ne(s.text)}}),i):u?e("div",{className:"p-4"},e("pre",{className:"bg-gray-900 rounded-lg border border-white/10 p-4 overflow-x-auto text-sm"},e("code",{className:`language-${Z[o]||o}`},s.text)),i):e("div",{className:"p-4"},e("pre",{className:"text-sm text-gray-300 whitespace-pre-wrap break-all font-mono"},s.text),i)}return e("div",{className:"flex flex-col items-center justify-center h-full gap-3 text-gray-400"},e("div",{className:"text-5xl"},C(n.name)),e("div",{className:"text-sm"},n.name),e("div",{className:"text-xs text-gray-600"},`${$(s.size)} \xB7 ${s.mime}`),e(M,{variant:"outline",size:"sm",className:"mt-2",onClick:()=>{let i=document.createElement("a");i.href=`/api/plugins/kanban-artifacts/raw?path=${encodeURIComponent(n.path)}`,i.download=n.name,i.click()}},"\u2B07 Download"))}function ne(s){return s?s.replace(/&/g,"&").replace(//g,">").replace(/```(\w*)\n([\s\S]*?)```/g,(n,o,r)=>`
${r.replace(/\n/g,"
")}
`).replace(/`([^`]+)`/g,"$1").replace(/^### (.+)$/gm,"

$1

").replace(/^## (.+)$/gm,"

$1

").replace(/^# (.+)$/gm,"

$1

").replace(/\*\*([^*]+)\*\*/g,"$1").replace(/\*([^*]+)\*/g,"$1").replace(/\[([^\]]+)\]\(([^)]+)\)/g,'$1').replace(/^> (.+)$/gm,'
$1
').replace(/^---$/gm,"
").replace(/^[-*] (.+)$/gm,"
  • $1
  • ").replace(/^\d+\. (.+)$/gm,"
  • $1
  • ").replace(/(
  • .*<\/li>\n?)+/g,n=>``).replace(/\n\n([^<])/g,"

    $1").replace(/\n/g,"
    "):""}window.__HERMES_PLUGINS__.register("kanban-artifacts",te)})();})(); diff --git a/kanban-artifacts/dashboard/dist/style.css b/kanban-artifacts/dashboard/dist/style.css new file mode 100644 index 0000000..b6c8510 --- /dev/null +++ b/kanban-artifacts/dashboard/dist/style.css @@ -0,0 +1,9 @@ +/* Kanban Artifacts — dashboard plugin styles */ + +:root { + --plugin-accent: #3b82f6; +} + +[data-plugin="kanban-artifacts"] { + font-family: inherit; +} \ No newline at end of file diff --git a/kanban-artifacts/dashboard/index.js b/kanban-artifacts/dashboard/index.js new file mode 100644 index 0000000..f7dd2e7 --- /dev/null +++ b/kanban-artifacts/dashboard/index.js @@ -0,0 +1,569 @@ +/** + * Hermes Kanban Artifact Viewer — Dashboard Plugin + * + * Plain IIFE using window.__HERMES_PLUGIN_SDK__ for React + shadcn primitives. + * Registers at /kanban-artifacts tab. + * + * SDK: + * window.__HERMES_PLUGINS__.register(name, Component) + * window.__HERMES_PLUGIN_SDK__.api.fetchJSON(path) → /api/plugins/kanban-artifacts/ + * window.__HERMES_PLUGIN_SDK__.components.* (Card, Badge, Button, Tabs, etc.) + * window.__HERMES_PLUGIN_SDK__.hooks.* (useState, useEffect, useCallback, useMemo) + * window.__HERMES_PLUGIN_SDK__.utils.* (cn, timeAgo, isoTimeAgo) + */ + +(function () { + "use strict"; + + const SDK = window.__HERMES_PLUGIN_SDK__; + if (!SDK) return; + + const { React } = SDK; + const h = React.createElement; + + const { + Card, CardContent, + Badge, Button, Input, + Label, Select, SelectOption, + Separator, Tabs, TabsList, TabsTrigger, + } = SDK.components; + + const { useState, useEffect, useCallback, useMemo, useRef } = SDK.hooks; + const { cn, timeAgo } = SDK.utils; + + const useI18n = SDK.useI18n || function () { + return { t: { kanbanArtifacts: null }, locale: "en" }; + }; + + function tx(t, path, fallback) { + let node = t && t.kanbanArtifacts; + if (node) { + const parts = path.split("."); + for (let i = 0; i < parts.length; i++) { + if (node && typeof node === "object" && parts[i] in node) { + node = node[parts[i]]; + } else { node = null; break; } + } + } + return (typeof node === "string") ? node : fallback; + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + async function apiFetch(path) { + // Plugin routes live under /api/plugins// + // The session token lives in window.__HERMES_SESSION_TOKEN__ (set by the dashboard SPA). + // We can't use credentials: 'include' with a cookie that doesn't exist — instead + // we forward the token explicitly as an Authorization header. + const url = `/api/plugins/kanban-artifacts${path}`; + const headers = { "Content-Type": "application/json" }; + const token = window.__HERMES_SESSION_TOKEN__; + if (token) headers["Authorization"] = `Bearer ${token}`; + const resp = await fetch(url, { credentials: "include", headers }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + return resp.json(); + } + + function escHtml(s) { + if (s === null || s === undefined) return ""; + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function formatSize(bytes) { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"; + if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + " MB"; + return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB"; + } + + function statusDotClass(status) { + const map = { + "todo": "bg-gray-400", + "in_progress": "bg-blue-500", + "running": "bg-yellow-500", + "done": "bg-green-500", + "completed": "bg-green-500", + "blocked": "bg-red-500", + }; + return map[status] || "bg-gray-400"; + } + + function fileIcon(name) { + const ext = name.split(".").pop().toLowerCase(); + const icons = { + md: "📝", markdown: "📝", txt: "📄", log: "📄", + py: "🐍", js: "🟨", ts: "🔷", jsx: "⚛️", tsx: "⚛️", + html: "🌐", css: "🎨", scss: "🎨", + json: "📋", yaml: "⚙️", yml: "⚙️", toml: "⚙️", + png: "🖼️", jpg: "🖼️", jpeg: "🖼️", gif: "🖼️", webp: "🖼️", svg: "🖼️", + pdf: "📕", + zip: "🗜️", tar: "🗜️", gz: "🗜️", + sh: "📟", bash: "📟", + sql: "🗃️", csv: "📊", + }; + return icons[ext] || "📎"; + } + + const CODE_EXTS = new Set([ + "py", "js", "ts", "jsx", "tsx", "html", "css", "scss", "sass", + "json", "yaml", "yml", "toml", "xml", "sql", "sh", "bash", + "zsh", "fish", "r", "lua", "pl", "rb", "go", "rs", "c", "cpp", + "h", "hpp", "java", "kt", "swift", "proto", "graphql", "gql", + "md", "markdown", "txt", "log", "env", + ]); + + const LANG_MAP = { + py: "python", js: "javascript", ts: "typescript", + jsx: "javascript", tsx: "typescript", + html: "html", css: "css", scss: "scss", + json: "json", yaml: "yaml", yml: "yaml", + xml: "xml", sql: "sql", sh: "bash", bash: "bash", + md: "markdown", txt: "plaintext", r: "r", + rb: "ruby", rs: "rust", go: "go", + c: "c", cpp: "cpp", h: "c", hpp: "cpp", + java: "java", kt: "kotlin", swift: "swift", + proto: "protobuf", graphql: "graphql", + }; + + // ── Components ──────────────────────────────────────────────────────────────── + + function Spinner({ className }) { + return h("div", { className: cn("animate-spin rounded-full h-4 w-4 border-2 border-current border-t-transparent", className) }); + } + + function FileItem({ file, active, onClick }) { + return h("div", { + className: cn( + "flex items-center gap-2 px-3 py-2 cursor-pointer rounded-md text-sm transition-colors", + active ? "bg-blue-500/20 text-blue-400" : "hover:bg-white/5 text-gray-200" + ), + onClick, + }, + h("span", { className: "text-base" }, fileIcon(file.name)), + h("span", { className: "flex-1 truncate" }, file.name), + h("span", { className: "text-gray-500 text-xs shrink-0" }, formatSize(file.size)) + ); + } + + // ── Main App ───────────────────────────────────────────────────────────────── + + function KanbanArtifactsApp() { + const { t } = useI18n(); + + const [boards, setBoards] = useState([]); + const [tasks, setTasks] = useState([]); + const [filesCache, setFilesCache] = useState({}); + const [tasksCache, setTasksCache] = useState({}); + const [currentBoard, setCurrentBoard] = useState(null); + const [currentTask, setCurrentTask] = useState(null); + const [currentFile, setCurrentFile] = useState(null); + const [fileContent, setFileContent] = useState(null); + const [loadingBoards, setLoadingBoards] = useState(true); + const [loadingFiles, setLoadingFiles] = useState(false); + const [loadingFile, setLoadingFile] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [fileError, setFileError] = useState(null); + const [expandedBoards, setExpandedBoards] = useState({}); + + // Column resizing + const [colWidths, setColWidths] = useState({ board: 280, file: 220 }); + const [resizing, setResizing] = useState(null); // 'board' | 'file' + const resizingRef = React.useRef(null); + + const onMouseMove = (e) => { + if (!resizingRef.current) return; + e.preventDefault(); + const startX = resizingRef.current.startX; + const startWidths = resizingRef.current.startWidths; + const delta = e.clientX - startX; + const next = { ...startWidths }; + if (resizingRef.current.side === 'board-file') { + next.board = Math.max(160, startWidths.board + delta); + } else { + next.file = Math.max(140, startWidths.file + delta); + } + setColWidths(next); + }; + const onMouseUp = () => { + setResizing(null); + resizingRef.current = null; + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + const startResize = (side, e) => { + e.preventDefault(); + resizingRef.current = { startX: e.clientX, startWidths: { ...colWidths }, side }; + setResizing(side); + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'col-resize'; + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }; + + // Load boards on mount + useEffect(() => { + apiFetch("/boards").then(b => { + setBoards(b); + setLoadingBoards(false); + if (b.length > 0 && !currentBoard) { + const first = b[0].slug; + setCurrentBoard(first); + setExpandedBoards({ [first]: true }); + } + }).catch(() => setLoadingBoards(false)); + }, []); + + // Load tasks when board changes + useEffect(() => { + if (!currentBoard) return; + if (tasksCache[currentBoard]) { setTasks(tasksCache[currentBoard]); return; } + apiFetch(`/boards/${currentBoard}/tasks`).then(data => { + setTasksCache(prev => ({ ...prev, [currentBoard]: data })); + setTasks(data); + }).catch(() => setTasks([])); + }, [currentBoard]); + + // Load files when task changes + useEffect(() => { + if (!currentTask) { setCurrentFile(null); setFileContent(null); return; } + const cached = filesCache[currentTask.id]; + if (cached !== undefined) { setCurrentFile(null); setFileContent(null); return; } + + setLoadingFiles(true); + setCurrentFile(null); + setFileContent(null); + + const url = currentTask.workspace_path + ? `/tasks/${currentTask.id}/files?path=${encodeURIComponent(currentTask.workspace_path)}` + : `/tasks/${currentTask.id}/files`; + + apiFetch(url).then(files => { + setFilesCache(prev => ({ ...prev, [currentTask.id]: files })); + setLoadingFiles(false); + }).catch(() => { setFilesCache(prev => ({ ...prev, [currentTask.id]: [] })); setLoadingFiles(false); }); + }, [currentTask]); + + // Load file content when file changes + useEffect(() => { + if (!currentFile) { setFileContent(null); setFileError(null); return; } + setLoadingFile(true); + setFileError(null); + apiFetch(`/files?path=${encodeURIComponent(currentFile.path)}`) + .then(data => { setFileContent(data); setLoadingFile(false); }) + .catch(err => { setFileError(String(err)); setLoadingFile(false); }); + }, [currentFile]); + + function toggleBoard(slug) { + setExpandedBoards(prev => ({ ...prev, [slug]: !prev[slug] })); + setCurrentBoard(slug); + } + + function selectTask(task) { + setCurrentTask(task); + setCurrentFile(null); + setFileContent(null); + } + + function selectFile(file) { + setCurrentFile(file); + } + + const filteredTasks = useMemo(() => { + if (!searchQuery) return tasks; + const q = searchQuery.toLowerCase(); + return tasks.filter(t => + (t.title || "").toLowerCase().includes(q) || + (t.id || "").toLowerCase().includes(q) + ); + }, [tasks, searchQuery]); + + const files = currentTask ? (filesCache[currentTask.id] || []) : []; + + const taskMeta = currentTask ? h("div", { className: "flex items-center gap-2 text-xs text-gray-400 mt-1" }, + h(Badge, { variant: currentTask.status === "done" ? "success" : "secondary", className: "text-xs" }, currentTask.status), + h("span", null, currentTask.assignee || "unassigned") + ) : null; + + return h("div", { className: "flex h-full overflow-hidden" }, + + // ── Sidebar: boards + tasks ───────────────────────────────────────────── + h("div", { className: "shrink-0 border-r border-white/10 flex flex-col overflow-hidden", style: { width: colWidths.board } }, + h("div", { className: "p-3 border-b border-white/10" }, + h("div", { className: "font-semibold text-sm text-gray-200 mb-2" }, tx(t, "title", "Kanban Artifacts")), + h(Input, { + placeholder: tx(t, "filter", "Filter tasks…"), + value: searchQuery, + onChange: e => setSearchQuery(e.target.value), + className: "h-8 text-xs", + }) + ), + + h("div", { className: "flex-1 overflow-y-auto p-2" }, + loadingBoards + ? h("div", { className: "flex justify-center py-8" }, h(Spinner)) + : boards.length === 0 + ? h("p", { className: "text-xs text-gray-500 px-2" }, "No boards found") + : boards.map(board => + h("div", { key: board.slug, className: "mb-1" }, + h("div", { + className: cn( + "flex items-center gap-1.5 px-2 py-1.5 rounded text-xs font-semibold cursor-pointer", + "text-gray-400 hover:text-gray-200 select-none transition-colors", + currentBoard === board.slug && "text-gray-200" + ), + onClick: () => toggleBoard(board.slug) + }, + h("span", { className: "text-gray-500 transition-transform", style: { transform: expandedBoards[board.slug] ? "rotate(90deg)" : "none" } }, "▶"), + h("span", null, board.slug) + ), + expandedBoards[board.slug] && h("div", { className: "mt-1" }, + filteredTasks.length === 0 + ? h("p", { className: "text-xs text-gray-600 px-2 py-1" }, "No tasks") + : filteredTasks.map(task => + h("div", { + key: task.id, + className: cn( + "flex items-start gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors", + "hover:bg-white/5", + currentTask && currentTask.id === task.id && "bg-blue-500/15" + ), + onClick: () => selectTask(task) + }, + h("span", { + className: cn("w-2 h-2 rounded-full mt-0.5 shrink-0", statusDotClass(task.status)) + }), + h("div", { className: "flex-1 min-w-0" }, + h("div", { className: "text-xs text-gray-300 truncate" }, task.title || "(untitled)"), + h("div", { className: "text-gray-600 text-xs truncate" }, task.id) + ) + ) + ) + ) + ) + ) + ) + ), + + // ── Resize handle: board ↔ file ───────────────────────────────────────── + h("div", { + className: "shrink-0 flex flex-col items-center justify-center cursor-col-resize group relative", + style: { width: 6 }, + onMouseDown: e => startResize('board-file', e), + onClick: e => e.preventDefault(), + }, + h("div", { className: "w-0.5 h-full bg-white/10 group-hover:bg-blue-400/60 transition-colors" }), + h("div", { + className: "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-6 rounded-full bg-white/10 group-hover:bg-blue-400/60 transition-colors hidden group-hover:flex items-center justify-center", + style: { pointerEvents: 'none' }, + }, + h("div", { className: "w-0.5 h-3 rounded-full bg-black/30" }) + ) + ), + + // ── File list ────────────────────────────────────────────────────────── + h("div", { className: "shrink-0 border-r border-white/10 flex flex-col overflow-hidden", style: { width: colWidths.file } }, + h("div", { className: "p-3 border-b border-white/10" }, + currentTask + ? h("div", null, + h("div", { className: "text-sm font-medium text-gray-200 truncate" }, currentTask.title || "(untitled)"), + taskMeta + ) + : h("div", { className: "text-xs text-gray-500" }, tx(t, "selectTask", "Select a task")) + ), + + h("div", { className: "flex-1 overflow-y-auto p-1" }, + !currentTask + ? h("p", { className: "text-xs text-gray-600 text-center py-8" }, "← Select a task") + : loadingFiles + ? h("div", { className: "flex justify-center py-8" }, h(Spinner)) + : files.length === 0 + ? h("p", { className: "text-xs text-gray-600 text-center py-8" }, "No files") + : files.map(f => + h(FileItem, { + key: f.path, + file: f, + active: currentFile && currentFile.path === f.path, + onClick: () => selectFile(f) + }) + ) + ) + ), + + // ── Resize handle: file ↔ preview ──────────────────────────────────────── + h("div", { + className: "shrink-0 flex flex-col items-center justify-center cursor-col-resize group relative", + style: { width: 6 }, + onMouseDown: e => startResize('file-preview', e), + onClick: e => e.preventDefault(), + }, + h("div", { className: "w-0.5 h-full bg-white/10 group-hover:bg-blue-400/60 transition-colors" }), + h("div", { + className: "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-6 rounded-full bg-white/10 group-hover:bg-blue-400/60 transition-colors hidden group-hover:flex items-center justify-center", + style: { pointerEvents: 'none' }, + }, + h("div", { className: "w-0.5 h-3 rounded-full bg-black/30" }) + ) + ), + + // ── File viewer ─────────────────────────────────────────────────────── + h("div", { className: "flex-1 flex flex-col overflow-hidden" }, + currentFile && h("div", { className: "flex items-center justify-between px-4 py-2 border-b border-white/10 bg-black/20" }, + h("div", { className: "flex items-center gap-2 min-w-0" }, + h("span", { className: "text-sm" }, fileIcon(currentFile.name)), + h("span", { className: "text-sm text-gray-200 truncate" }, currentFile.name), + ), + h("div", { className: "flex gap-2 shrink-0" }, + h(Button, { + variant: "ghost", + size: "sm", + className: "text-xs h-7", + onClick: () => { + const a = document.createElement("a"); + a.href = `/api/plugins/kanban-artifacts/raw?path=${encodeURIComponent(currentFile.path)}`; + a.download = currentFile.name; + a.click(); + } + }, "⬇ Download") + ) + ), + + h("div", { className: "flex-1 overflow-y-auto" }, + !currentFile + ? h("div", { className: "flex items-center justify-center h-full text-gray-500 text-sm" }, + "← Select a file to preview" + ) + : loadingFile + ? h("div", { className: "flex justify-center items-center h-full" }, h(Spinner)) + : fileError + ? h("div", { className: "text-red-400 text-sm p-4" }, "Error: " + fileError) + : fileContent && renderContent(fileContent, currentFile) + ) + ) + ); + } + + // ── Content renderer ───────────────────────────────────────────────────────── + + function renderContent(data, file) { + const ext = file.name.split(".").pop().toLowerCase(); + const isMarkdown = ["md", "markdown"].includes(ext); + const isCode = CODE_EXTS.has(ext); + + // Image + if (data.mime && data.mime.startsWith("image/")) { + return h("div", { className: "p-4 flex justify-center" }, + h("img", { + src: `/api/plugins/kanban-artifacts/raw?path=${encodeURIComponent(file.path)}`, + alt: file.name, + className: "max-w-full rounded-lg border border-white/10" + }) + ); + } + + // Text / code / markdown + if (data.text !== undefined && data.text !== null) { + const truncated = data.truncated + ? h("div", { className: "text-yellow-500 text-xs px-4 pt-3" }, + `⚠ File truncated (${formatSize(data.size)}). Download to view full content.` + ) + : null; + + if (isMarkdown) { + // Simple markdown rendering (no external deps in plugin) + return h("div", { className: "p-4 max-w-3xl" }, + h("div", { + className: "prose prose-invert prose-sm max-w-none", + dangerouslySetInnerHTML: { __html: renderMarkdown(data.text) } + }), + truncated + ); + } + + if (isCode) { + return h("div", { className: "p-4" }, + h("pre", { className: "bg-gray-900 rounded-lg border border-white/10 p-4 overflow-x-auto text-sm" }, + h("code", { className: `language-${LANG_MAP[ext] || ext}` }, data.text) + ), + truncated + ); + } + + // Plain text + return h("div", { className: "p-4" }, + h("pre", { className: "text-sm text-gray-300 whitespace-pre-wrap break-all font-mono" }, data.text), + truncated + ); + } + + // Binary fallback + return h("div", { className: "flex flex-col items-center justify-center h-full gap-3 text-gray-400" }, + h("div", { className: "text-5xl" }, fileIcon(file.name)), + h("div", { className: "text-sm" }, file.name), + h("div", { className: "text-xs text-gray-600" }, `${formatSize(data.size)} · ${data.mime}`), + h(Button, { + variant: "outline", + size: "sm", + className: "mt-2", + onClick: () => { + const a = document.createElement("a"); + a.href = `/api/plugins/kanban-artifacts/raw?path=${encodeURIComponent(file.path)}`; + a.download = file.name; + a.click(); + } + }, "⬇ Download") + ); + } + + // ── Minimal Markdown renderer (no external deps) ───────────────────────────── + + function renderMarkdown(text) { + if (!text) return ""; + return text + // Escape HTML first + .replace(/&/g, "&").replace(//g, ">") + // Code blocks + .replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => + `

    ${code.replace(/\n/g, "
    ")}
    `) + // Inline code + .replace(/`([^`]+)`/g, "$1") + // Headers + .replace(/^### (.+)$/gm, "

    $1

    ") + .replace(/^## (.+)$/gm, "

    $1

    ") + .replace(/^# (.+)$/gm, "

    $1

    ") + // Bold / italic + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + // Links + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + // Blockquote + .replace(/^> (.+)$/gm, '
    $1
    ') + // HR + .replace(/^---$/gm, "
    ") + // Unordered lists + .replace(/^[-*] (.+)$/gm, "
  • $1
  • ") + // Ordered lists + .replace(/^\d+\. (.+)$/gm, "
  • $1
  • ") + // Wrap consecutive
  • in