From b742486c5332c7291753624d422b546482c4de3f Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Mon, 9 Feb 2026 13:30:14 +0200 Subject: [PATCH 1/3] fix: add deliverables --- .../agents-panel/agents-panel-item.tsx | 10 +- .../agents-panel/agents-panel-list.tsx | 10 +- apps/web/src/app/(dashboard)/board/page.tsx | 13 +- apps/web/src/components/chat/chat-message.tsx | 37 +- .../web/src/components/chat/chat-messages.tsx | 20 +- .../_components/document-viewer-modal.tsx | 115 +++ .../kanban/_components/documents-section.tsx | 74 ++ .../web/src/components/kanban/kanban-card.tsx | 52 +- .../components/kanban/task-detail-modal.tsx | 119 ++-- apps/web/src/components/kanban/types.ts | 1 + apps/web/src/components/live-feed/types.ts | 21 +- apps/web/src/hooks/use-chat.ts | 44 +- packages/backend/convex/_generated/api.d.ts | 2 + packages/backend/convex/documents.ts | 19 +- packages/backend/convex/schema.ts | 1 + packages/backend/convex/tasks.ts | 12 +- packages/backend/convex/types.ts | 82 +++ packages/backend/package.json | 4 + packages/cli/src/client.ts | 65 ++ packages/cli/src/commands/deliver.ts | 20 +- plans/steps.md | 654 ++++++++++++++++++ 21 files changed, 1225 insertions(+), 150 deletions(-) create mode 100644 apps/web/src/components/kanban/_components/document-viewer-modal.tsx create mode 100644 apps/web/src/components/kanban/_components/documents-section.tsx create mode 100644 packages/backend/convex/types.ts create mode 100644 plans/steps.md diff --git a/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel-item.tsx b/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel-item.tsx index 9e07b5c..8b3fb28 100644 --- a/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel-item.tsx +++ b/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel-item.tsx @@ -6,6 +6,7 @@ import { TooltipContent, TooltipTrigger, } from "@clawe/ui/components/tooltip"; +import type { Agent } from "@clawe/backend/types"; type AgentStatus = "idle" | "active" | "blocked"; @@ -68,14 +69,7 @@ const getAvatarColor = (name: string) => { }; export type AgentsPanelItemProps = { - agent: { - _id: string; - name: string; - emoji?: string; - role: string; - status: string; - lastSeen?: number; - }; + agent: Agent; collapsed?: boolean; selected?: boolean; onToggle?: () => void; diff --git a/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel-list.tsx b/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel-list.tsx index 9bdd743..b77c113 100644 --- a/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel-list.tsx +++ b/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel-list.tsx @@ -2,17 +2,9 @@ import { ScrollArea } from "@clawe/ui/components/scroll-area"; import { cn } from "@clawe/ui/lib/utils"; +import type { Agent } from "@clawe/backend/types"; import { AgentsPanelItem } from "./agents-panel-item"; -type Agent = { - _id: string; - name: string; - emoji?: string; - role: string; - status: string; - lastSeen?: number; -}; - export type AgentsPanelListProps = { agents: Agent[]; collapsed?: boolean; diff --git a/apps/web/src/app/(dashboard)/board/page.tsx b/apps/web/src/app/(dashboard)/board/page.tsx index 86622f2..29a6ae6 100644 --- a/apps/web/src/app/(dashboard)/board/page.tsx +++ b/apps/web/src/app/(dashboard)/board/page.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useQuery } from "convex/react"; import { api } from "@clawe/backend"; +import type { TaskWithAssignees } from "@clawe/backend/types"; import { Bell } from "lucide-react"; import { Button } from "@clawe/ui/components/button"; import { @@ -38,17 +39,8 @@ function mapPriority(priority?: string): "low" | "medium" | "high" { } } -type ConvexTask = { - _id: string; - title: string; - description?: string; - priority?: string; - assignees?: { _id: string; name: string; emoji?: string }[]; - subtasks?: { title: string; description?: string; done: boolean }[]; -}; - // Map Convex task to Kanban task format -function mapTask(task: ConvexTask): KanbanTask { +function mapTask(task: TaskWithAssignees): KanbanTask { const subtasks: KanbanTask[] = task.subtasks ?.filter((st) => !st.done) @@ -69,6 +61,7 @@ function mapTask(task: ConvexTask): KanbanTask { ? `${task.assignees[0].emoji || ""} ${task.assignees[0].name}`.trim() : undefined, subtasks, + documentCount: task.documentCount, }; } diff --git a/apps/web/src/components/chat/chat-message.tsx b/apps/web/src/components/chat/chat-message.tsx index 0b43445..c37eba1 100644 --- a/apps/web/src/components/chat/chat-message.tsx +++ b/apps/web/src/components/chat/chat-message.tsx @@ -31,13 +31,13 @@ export const ChatMessage = ({ message, className }: ChatMessageProps) => { if (isContext) { return (
-
+
Context -
+
{ {children} ), - code: ({ children, ...props }) => ( - { + const isInline = !codeClassName; + return isInline ? ( + + {children} + + ) : ( + + {children} + + ); + }, + pre: ({ children, ...props }) => ( +
                       {children}
-                    
+                    
), }} > @@ -108,22 +123,24 @@ export const ChatMessage = ({ message, className }: ChatMessageProps) => { {/* Message Content */}
{isUser ? ( -

{message.content}

+

+ {message.content} +

) : ( -
+
+
+ + Loading messages... +
+
+ ); + } + + if (!hasMessages && !isStreaming) { return ; } diff --git a/apps/web/src/components/kanban/_components/document-viewer-modal.tsx b/apps/web/src/components/kanban/_components/document-viewer-modal.tsx new file mode 100644 index 0000000..1f83c94 --- /dev/null +++ b/apps/web/src/components/kanban/_components/document-viewer-modal.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useEffect, useState } from "react"; +import axios from "axios"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@clawe/ui/components/dialog"; +import { Button } from "@clawe/ui/components/button"; +import { Download } from "lucide-react"; +import { Spinner } from "@clawe/ui/components/spinner"; +import { cn } from "@clawe/ui/lib/utils"; +import type { DocumentWithCreator } from "@clawe/backend/types"; + +const VIEWER_HEIGHT = "h-[500px]"; + +export type DocumentViewerModalProps = { + document: DocumentWithCreator | null; + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export const DocumentViewerModal = ({ + document, + open, + onOpenChange, +}: DocumentViewerModalProps) => { + const [fileContent, setFileContent] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + const fileUrl = document?.fileUrl; + if (!fileUrl || !open) { + setFileContent(null); + return; + } + + const controller = new AbortController(); + + const fetchContent = async () => { + setIsLoading(true); + try { + const response = await axios.get(fileUrl, { + responseType: "text", + signal: controller.signal, + }); + setFileContent(response.data); + } catch (error) { + if (!axios.isCancel(error)) { + setFileContent(null); + } + } finally { + setIsLoading(false); + } + }; + + void fetchContent(); + + return () => { + controller.abort(); + }; + }, [document?.fileUrl, open]); + + if (!document) return null; + + const content = fileContent ?? document.content; + + return ( + + + + + {document.title} + {document.fileUrl && ( + + )} + + + + {isLoading ? ( +
+ +
+ ) : content ? ( +
+
{content}
+
+ ) : ( +
+

+ No preview available +

+
+ )} +
+
+ ); +}; diff --git a/apps/web/src/components/kanban/_components/documents-section.tsx b/apps/web/src/components/kanban/_components/documents-section.tsx new file mode 100644 index 0000000..7d2b03e --- /dev/null +++ b/apps/web/src/components/kanban/_components/documents-section.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useQuery } from "convex/react"; +import { api } from "@clawe/backend"; +import type { Id } from "@clawe/backend/dataModel"; +import type { DocumentWithCreator } from "@clawe/backend/types"; +import { FileText, Download, Eye } from "lucide-react"; +import { Button } from "@clawe/ui/components/button"; + +export type DocumentsSectionProps = { + taskId: string; + onViewDocument: (doc: DocumentWithCreator) => void; +}; + +export const DocumentsSection = ({ + taskId, + onViewDocument, +}: DocumentsSectionProps) => { + const documents = useQuery(api.documents.getForTask, { + taskId: taskId as Id<"tasks">, + }); + + // Filter to only show deliverables + const deliverables = documents?.filter((d) => d.type === "deliverable") ?? []; + + if (deliverables.length === 0) { + return null; + } + + return ( +
+

+ Documents ({deliverables.length}) +

+
    + {deliverables.map((doc) => ( +
  • +
    + + {doc.title} +
    +
    + {doc.fileUrl && ( + <> + + + + )} +
    +
  • + ))} +
+
+ ); +}; diff --git a/apps/web/src/components/kanban/kanban-card.tsx b/apps/web/src/components/kanban/kanban-card.tsx index 1fbd188..a9bc63b 100644 --- a/apps/web/src/components/kanban/kanban-card.tsx +++ b/apps/web/src/components/kanban/kanban-card.tsx @@ -1,7 +1,13 @@ "use client"; import { useState } from "react"; -import { ChevronDown, ChevronRight, AlignLeft, User } from "lucide-react"; +import { + ChevronDown, + ChevronRight, + AlignLeft, + User, + FileText, +} from "lucide-react"; import { cn } from "@clawe/ui/lib/utils"; import { Popover, @@ -120,21 +126,35 @@ export const KanbanCard = ({
)} - {/* Subtask toggle */} - {hasSubtasks && !isSubtask && ( - - )} + {/* Subtask toggle and document badge */} + {(hasSubtasks || (task.documentCount && task.documentCount > 0)) && + !isSubtask && ( +
+ {hasSubtasks && ( + + )} + + {task.documentCount && task.documentCount > 0 && ( + + + {task.documentCount} document + {task.documentCount !== 1 && "s"} + + )} +
+ )}
{/* Expanded subtasks */} diff --git a/apps/web/src/components/kanban/task-detail-modal.tsx b/apps/web/src/components/kanban/task-detail-modal.tsx index ea820b0..fd70e5b 100644 --- a/apps/web/src/components/kanban/task-detail-modal.tsx +++ b/apps/web/src/components/kanban/task-detail-modal.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { Dialog, DialogContent, @@ -9,6 +10,9 @@ import { import { cn } from "@clawe/ui/lib/utils"; import { Circle } from "lucide-react"; import type { KanbanTask } from "./types"; +import type { DocumentWithCreator } from "@clawe/backend/types"; +import { DocumentsSection } from "./_components/documents-section"; +import { DocumentViewerModal } from "./_components/document-viewer-modal"; const priorityConfig: Record< KanbanTask["priority"], @@ -41,63 +45,80 @@ export const TaskDetailModal = ({ open, onOpenChange, }: TaskDetailModalProps) => { + const [selectedDocument, setSelectedDocument] = + useState(null); + if (!task) return null; const priority = priorityConfig[task.priority]; const hasSubtasks = task.subtasks.length > 0; return ( - - - - {/* Title */} - {task.title} - - -
- {/* Priority badge */} -
- - {priority.label} Priority - -
+ <> + + + + {task.title} + - {/* Description */} - {task.description && ( -
-

- Description -

-

{task.description}

+
+ {/* Priority badge */} +
+ + {priority.label} Priority +
- )} - {/* Subtasks list */} - {hasSubtasks && ( -
-

- Subtasks ({task.subtasks.length}) -

-
    - {task.subtasks.map((subtask) => ( -
  • - - {subtask.title} -
  • - ))} -
-
- )} -
- -
+ {/* Description */} + {task.description && ( +
+

+ Description +

+

{task.description}

+
+ )} + + {/* Subtasks list */} + {hasSubtasks && ( +
+

+ Subtasks ({task.subtasks.length}) +

+
    + {task.subtasks.map((subtask) => ( +
  • + + {subtask.title} +
  • + ))} +
+
+ )} + + {/* Documents section */} + +
+
+
+ + {/* Document viewer modal */} + !isOpen && setSelectedDocument(null)} + /> + ); }; diff --git a/apps/web/src/components/kanban/types.ts b/apps/web/src/components/kanban/types.ts index e06d307..dd1908f 100644 --- a/apps/web/src/components/kanban/types.ts +++ b/apps/web/src/components/kanban/types.ts @@ -6,6 +6,7 @@ export type KanbanTask = { priority: "low" | "medium" | "high"; assignee?: string; subtasks: KanbanTask[]; + documentCount?: number; }; // Predefined column variants with built-in styling diff --git a/apps/web/src/components/live-feed/types.ts b/apps/web/src/components/live-feed/types.ts index c6358bd..623b97a 100644 --- a/apps/web/src/components/live-feed/types.ts +++ b/apps/web/src/components/live-feed/types.ts @@ -1,20 +1,7 @@ -import type { Doc, Id } from "@clawe/backend/dataModel"; +import type { ActivityType, ActivityWithDetails } from "@clawe/backend/types"; -// Activity type from backend schema -export type ActivityType = Doc<"activities">["type"]; - -// Enriched activity returned by activities.feed query -export type FeedActivity = Doc<"activities"> & { - agent: { - _id: Id<"agents">; - name: string; - emoji?: string; - } | null; - task: { - _id: Id<"tasks">; - title: string; - status: string; - } | null; -}; +// Re-export from shared types for convenience +export type { ActivityType }; +export type FeedActivity = ActivityWithDetails; export type FeedFilter = "all" | "tasks" | "status" | "heartbeats"; diff --git a/apps/web/src/hooks/use-chat.ts b/apps/web/src/hooks/use-chat.ts index 08dad09..42e45bd 100644 --- a/apps/web/src/hooks/use-chat.ts +++ b/apps/web/src/hooks/use-chat.ts @@ -1,6 +1,7 @@ "use client"; import { useState, useCallback, useRef } from "react"; +import axios from "axios"; import type { ChatAttachment } from "@/components/chat/types"; export type Message = { @@ -196,35 +197,26 @@ export const useChat = ({ setStatus("loading"); try { - const response = await fetch( + const response = await axios.get<{ messages?: unknown[] }>( `/api/chat/history?sessionKey=${encodeURIComponent(sessionKey)}&limit=200`, - { cache: "no-store" }, ); - if (!response.ok) { - // History loading failed - just continue without history - console.warn("[chat] Failed to load history, continuing without"); - setStatus("idle"); - return; - } - - const data = await response.json(); - const historyMessages: Message[] = (data.messages || []) - .map( - ( - msg: { role?: string; content?: unknown }, - i: number, - ): Message | null => { - const content = extractTextContent(msg.content); - if (msg.role === "system" || isSystemMessage(content)) return null; - return { - id: `history_${i}`, - role: msg.role === "user" ? "user" : "assistant", - content, - }; - }, - ) - .filter((m: Message | null): m is Message => m !== null); + const data = response.data; + const messages = (data.messages || []) as Array<{ + role?: string; + content?: unknown; + }>; + const historyMessages: Message[] = messages + .map((msg, i): Message | null => { + const content = extractTextContent(msg.content); + if (msg.role === "system" || isSystemMessage(content)) return null; + return { + id: `history_${i}`, + role: msg.role === "user" ? "user" : "assistant", + content, + }; + }) + .filter((m): m is Message => m !== null); setMessages(historyMessages); setStatus("idle"); diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index 6aa1cad..533b7a0 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -17,6 +17,7 @@ import type * as messages from "../messages.js"; import type * as notifications from "../notifications.js"; import type * as settings from "../settings.js"; import type * as tasks from "../tasks.js"; +import type * as types from "../types.js"; import type { ApiFromModules, @@ -34,6 +35,7 @@ declare const fullApi: ApiFromModules<{ notifications: typeof notifications; settings: typeof settings; tasks: typeof tasks; + types: typeof types; }>; /** diff --git a/packages/backend/convex/documents.ts b/packages/backend/convex/documents.ts index be9d788..e166dd5 100644 --- a/packages/backend/convex/documents.ts +++ b/packages/backend/convex/documents.ts @@ -1,5 +1,13 @@ import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; +import { action, mutation, query } from "./_generated/server"; + +// Generate upload URL for file storage +export const generateUploadUrl = action({ + args: {}, + handler: async (ctx) => { + return await ctx.storage.generateUploadUrl(); + }, +}); // List all documents export const list = query({ @@ -39,12 +47,17 @@ export const getForTask = query({ .withIndex("by_task", (q) => q.eq("taskId", args.taskId)) .collect(); - // Enrich with creator info + // Enrich with creator info and file URL return Promise.all( documents.map(async (doc) => { const creator = await ctx.db.get(doc.createdBy); + const fileUrl = doc.fileId + ? await ctx.storage.getUrl(doc.fileId) + : null; + return { ...doc, + fileUrl, creator: creator ? { _id: creator._id, name: creator.name, emoji: creator.emoji } : null, @@ -121,6 +134,7 @@ export const registerDeliverable = mutation({ args: { title: v.string(), path: v.string(), + fileId: v.optional(v.id("_storage")), taskId: v.id("tasks"), createdBySessionKey: v.string(), }, @@ -141,6 +155,7 @@ export const registerDeliverable = mutation({ const documentId = await ctx.db.insert("documents", { title: args.title, path: args.path, + fileId: args.fileId, type: "deliverable", taskId: args.taskId, createdBy: agent._id, diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index ac588d3..c9b6a53 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -143,6 +143,7 @@ export default defineSchema({ title: v.string(), content: v.optional(v.string()), // Markdown content (for text docs) path: v.optional(v.string()), // File path (for file deliverables) + fileId: v.optional(v.id("_storage")), // Convex storage ID for uploaded files type: v.union( v.literal("deliverable"), v.literal("research"), diff --git a/packages/backend/convex/tasks.ts b/packages/backend/convex/tasks.ts index bf5e366..4fcb212 100644 --- a/packages/backend/convex/tasks.ts +++ b/packages/backend/convex/tasks.ts @@ -38,7 +38,7 @@ export const list = query({ tasks = tasks.slice(0, args.limit); } - // Enrich with assignee info + // Enrich with assignee info and document count return Promise.all( tasks.map(async (task) => { const assignees = task.assigneeIds @@ -49,6 +49,15 @@ export const list = query({ (a): a is NonNullable => a !== null, ); + // Get deliverable count for this task + const documents = await ctx.db + .query("documents") + .withIndex("by_task", (q) => q.eq("taskId", task._id)) + .collect(); + const documentCount = documents.filter( + (d) => d.type === "deliverable", + ).length; + return { ...task, assignees: validAssignees.map((a) => ({ @@ -56,6 +65,7 @@ export const list = query({ name: a.name, emoji: a.emoji, })), + documentCount, }; }), ); diff --git a/packages/backend/convex/types.ts b/packages/backend/convex/types.ts new file mode 100644 index 0000000..bf5ef13 --- /dev/null +++ b/packages/backend/convex/types.ts @@ -0,0 +1,82 @@ +/** + * Shared types for Convex query results. + * + * These types represent the enriched data returned by queries, + * including joined/computed fields. Use these instead of + * duplicating type definitions in frontend components. + */ + +import type { Doc, Id } from "./_generated/dataModel"; + +// ============================================================================= +// Agent Types +// ============================================================================= + +/** Agent as returned by agents.list query */ +export type Agent = Doc<"agents">; + +/** Minimal agent info used in enriched results */ +export type AgentSummary = { + _id: Id<"agents">; + name: string; + emoji?: string; +}; + +// ============================================================================= +// Task Types +// ============================================================================= + +/** Task as returned by tasks.list query (with enriched assignees and document count) */ +export type TaskWithAssignees = Doc<"tasks"> & { + assignees: AgentSummary[]; + documentCount: number; +}; + +/** Subtask within a task (from embedded array) */ +export type Subtask = NonNullable["subtasks"]>[number]; + +/** Enriched subtask with assignee info */ +export type SubtaskWithAssignee = Subtask & { + assignee: AgentSummary | null; +}; + +// ============================================================================= +// Document Types +// ============================================================================= + +/** Document as returned by documents.getForTask query (with enriched creator and file URL) */ +export type DocumentWithCreator = Doc<"documents"> & { + fileUrl: string | null; + creator: AgentSummary | null; +}; + +// ============================================================================= +// Activity Types +// ============================================================================= + +/** Activity type enum from schema */ +export type ActivityType = Doc<"activities">["type"]; + +/** Activity as returned by activities.feed query (with enriched agent and task) */ +export type ActivityWithDetails = Doc<"activities"> & { + agent: AgentSummary | null; + task: { + _id: Id<"tasks">; + title: string; + status: string; + } | null; +}; + +// ============================================================================= +// Notification Types +// ============================================================================= + +/** Notification with enriched source agent and task */ +export type NotificationWithDetails = Doc<"notifications"> & { + sourceAgent: AgentSummary | null; + task: { + _id: Id<"tasks">; + title: string; + status: string; + } | null; +}; diff --git a/packages/backend/package.json b/packages/backend/package.json index d6f96cb..bb92fe8 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -15,6 +15,10 @@ "./dataModel": { "types": "./convex/_generated/dataModel.d.ts", "default": "./convex/_generated/dataModel.js" + }, + "./types": { + "types": "./convex/types.ts", + "default": "./convex/types.ts" } }, "scripts": { diff --git a/packages/cli/src/client.ts b/packages/cli/src/client.ts index e4a9c1a..87be529 100644 --- a/packages/cli/src/client.ts +++ b/packages/cli/src/client.ts @@ -1,10 +1,75 @@ import { ConvexHttpClient } from "convex/browser"; +import { api } from "@clawe/backend"; +import * as fs from "fs"; +import * as path from "path"; const CONVEX_URL = process.env.CONVEX_URL; +// Common MIME types by extension +const MIME_TYPES: Record = { + ".md": "text/markdown", + ".txt": "text/plain", + ".html": "text/html", + ".css": "text/css", + ".js": "application/javascript", + ".ts": "application/typescript", + ".json": "application/json", + ".xml": "application/xml", + ".pdf": "application/pdf", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".webp": "image/webp", + ".mp3": "audio/mpeg", + ".mp4": "video/mp4", + ".zip": "application/zip", + ".csv": "text/csv", +}; + if (!CONVEX_URL) { console.error("Error: CONVEX_URL environment variable is required"); process.exit(1); } export const client = new ConvexHttpClient(CONVEX_URL); + +/** + * Upload a file to Convex storage + * @returns The storage ID of the uploaded file + */ +export async function uploadFile(filePath: string): Promise { + // Read file + const fileBuffer = fs.readFileSync(filePath); + + // Get upload URL from Convex + let uploadUrl = await client.action(api.documents.generateUploadUrl, {}); + + // When running in Docker, rewrite localhost/127.0.0.1 to host.docker.internal + // so the container can reach the host's Convex dev server + if (process.env.CONVEX_URL?.includes("host.docker.internal")) { + uploadUrl = uploadUrl.replace( + /localhost|127\.0\.0\.1/, + "host.docker.internal", + ); + } + + // Detect content type from file extension + const ext = path.extname(filePath).toLowerCase(); + const contentType = MIME_TYPES[ext] || "application/octet-stream"; + + // Upload file + const response = await fetch(uploadUrl, { + method: "POST", + headers: { "Content-Type": contentType }, + body: fileBuffer, + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.statusText}`); + } + + const result = (await response.json()) as { storageId: string }; + return result.storageId; +} diff --git a/packages/cli/src/commands/deliver.ts b/packages/cli/src/commands/deliver.ts index e750ea6..1d512e6 100644 --- a/packages/cli/src/commands/deliver.ts +++ b/packages/cli/src/commands/deliver.ts @@ -1,6 +1,7 @@ -import { client } from "../client.js"; +import { client, uploadFile } from "../client.js"; import { api } from "@clawe/backend"; import type { Id } from "@clawe/backend/dataModel"; +import * as fs from "fs"; interface DeliverOptions { by: string; @@ -12,9 +13,23 @@ export async function deliver( title: string, options: DeliverOptions, ): Promise { + // Check if file exists + if (!fs.existsSync(path)) { + throw new Error(`File not found: ${path}`); + } + + console.log(`📤 Uploading file: ${path}...`); + + // Upload file to Convex storage + const fileId = await uploadFile(path); + + console.log(`✅ File uploaded`); + + // Register deliverable with fileId await client.mutation(api.documents.registerDeliverable, { taskId: taskId as Id<"tasks">, path, + fileId: fileId as Id<"_storage">, title, createdBySessionKey: options.by, }); @@ -38,6 +53,9 @@ export async function deliverables(taskId: string): Promise { const date = new Date(doc.createdAt).toLocaleString(); console.log(`${doc.title}`); console.log(` Path: ${doc.path}`); + if (doc.fileUrl) { + console.log(` URL: ${doc.fileUrl}`); + } console.log(` By: ${creator} at ${date}`); console.log(); } diff --git a/plans/steps.md b/plans/steps.md new file mode 100644 index 0000000..eebe443 --- /dev/null +++ b/plans/steps.md @@ -0,0 +1,654 @@ +# Document Viewing Feature - Implementation Plan + +## Overview + +Enable users to view and download deliverable files created by agents, directly from the kanban board. Files will be stored in Convex file storage and served via CDN-backed URLs. + +## Architecture + +``` +Agent creates file → CLI uploads to Convex → Document record updated with fileId + ↓ +User clicks task → Modal shows documents → Click "View" → File served from Convex CDN +``` + +--- + +## Phase 1: Schema & Backend + +### Step 1.1: Update Documents Schema + +**File:** `packages/backend/convex/schema.ts` + +Add `fileId` field to documents table: + +```typescript +documents: defineTable({ + title: v.string(), + content: v.optional(v.string()), + path: v.optional(v.string()), + fileId: v.optional(v.id("_storage")), // NEW: Convex storage ID + type: v.union( + v.literal("deliverable"), + v.literal("research"), + v.literal("reference"), + v.literal("note"), + ), + taskId: v.optional(v.id("tasks")), + createdBy: v.id("agents"), + createdAt: v.number(), + updatedAt: v.number(), +}); +``` + +### Step 1.2: Create Upload Action + +**File:** `packages/backend/convex/documents.ts` + +Add action to generate upload URL: + +```typescript +import { action } from "./_generated/server"; + +export const generateUploadUrl = action({ + args: {}, + handler: async (ctx) => { + return await ctx.storage.generateUploadUrl(); + }, +}); +``` + +### Step 1.3: Create File URL Query + +**File:** `packages/backend/convex/documents.ts` + +Add query to get file URL: + +```typescript +export const getFileUrl = query({ + args: { fileId: v.id("_storage") }, + handler: async (ctx, args) => { + return await ctx.storage.getUrl(args.fileId); + }, +}); +``` + +### Step 1.4: Update registerDeliverable Mutation + +**File:** `packages/backend/convex/documents.ts` + +Modify to accept optional `fileId`: + +```typescript +export const registerDeliverable = mutation({ + args: { + title: v.string(), + path: v.string(), + fileId: v.optional(v.id("_storage")), // NEW + taskId: v.id("tasks"), + createdBySessionKey: v.string(), + }, + handler: async (ctx, args) => { + // ... existing logic + const documentId = await ctx.db.insert("documents", { + title: args.title, + path: args.path, + fileId: args.fileId, // NEW + type: "deliverable", + taskId: args.taskId, + createdBy: agent._id, + createdAt: now, + updatedAt: now, + }); + // ... rest + }, +}); +``` + +### Step 1.5: Update getForTask Query + +**File:** `packages/backend/convex/documents.ts` + +Include file URLs in response: + +```typescript +export const getForTask = query({ + args: { taskId: v.id("tasks") }, + handler: async (ctx, args) => { + const documents = await ctx.db + .query("documents") + .withIndex("by_task", (q) => q.eq("taskId", args.taskId)) + .collect(); + + return Promise.all( + documents.map(async (doc) => { + const creator = await ctx.db.get(doc.createdBy); + // Get file URL if fileId exists + const fileUrl = doc.fileId + ? await ctx.storage.getUrl(doc.fileId) + : null; + + return { + ...doc, + fileUrl, // NEW: Include download URL + creator: creator + ? { _id: creator._id, name: creator.name, emoji: creator.emoji } + : null, + }; + }), + ); + }, +}); +``` + +--- + +## Phase 2: CLI Updates + +### Step 2.1: Update Client for File Upload + +**File:** `packages/cli/src/client.ts` + +Add helper for file upload: + +```typescript +import { ConvexHttpClient } from "convex/browser"; +import { api } from "@clawe/backend"; + +// ... existing client export + +export async function uploadFile(filePath: string): Promise { + const fs = await import("fs/promises"); + + // Read file + const fileBuffer = await fs.readFile(filePath); + const fileName = filePath.split("/").pop() || "file"; + + // Get upload URL from Convex + const uploadUrl = await client.action(api.documents.generateUploadUrl, {}); + + // Upload file + const response = await fetch(uploadUrl, { + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + body: fileBuffer, + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.statusText}`); + } + + const { storageId } = await response.json(); + return storageId; +} +``` + +### Step 2.2: Update Deliver Command + +**File:** `packages/cli/src/commands/deliver.ts` + +Modify to upload file before registering: + +```typescript +import { client, uploadFile } from "../client.js"; +import { api } from "@clawe/backend"; +import type { Id } from "@clawe/backend/dataModel"; +import * as fs from "fs"; + +interface DeliverOptions { + by: string; +} + +export async function deliver( + taskId: string, + path: string, + title: string, + options: DeliverOptions, +): Promise { + // Check if file exists + if (!fs.existsSync(path)) { + console.error(`Error: File not found: ${path}`); + process.exit(1); + } + + console.log(`📤 Uploading file: ${path}...`); + + // Upload file to Convex storage + const fileId = await uploadFile(path); + + console.log(`✅ File uploaded`); + + // Register deliverable with fileId + await client.mutation(api.documents.registerDeliverable, { + taskId: taskId as Id<"tasks">, + path, + fileId: fileId as Id<"_storage">, + title, + createdBySessionKey: options.by, + }); + + console.log(`✅ Deliverable registered: ${title}`); +} +``` + +### Step 2.3: Rebuild CLI + +```bash +cd packages/cli +pnpm build +``` + +--- + +## Phase 3: Frontend - Types & Data + +### Step 3.1: Update Kanban Types + +**File:** `apps/web/src/components/kanban/types.ts` + +Add document types: + +```typescript +export type KanbanDocument = { + id: string; + title: string; + type: "deliverable" | "research" | "reference" | "note"; + path?: string; + fileUrl?: string | null; + createdBy?: string; + createdAt: number; +}; + +export type KanbanTask = { + id: string; + title: string; + description?: string; + priority: "low" | "medium" | "high"; + assignee?: string; + subtasks: KanbanTask[]; + documentCount?: number; // NEW: For badge on card +}; +``` + +### Step 3.2: Update Board Page Task Mapping + +**File:** `apps/web/src/app/(dashboard)/board/page.tsx` + +Include document count in task mapping: + +```typescript +// Update ConvexTask type to include document count if available +type ConvexTask = { + _id: string; + title: string; + description?: string; + priority?: string; + assignees?: { _id: string; name: string; emoji?: string }[]; + subtasks?: { title: string; description?: string; done: boolean }[]; + // Note: document count will be added via separate query or enrichment +}; + +function mapTask(task: ConvexTask, documentCount?: number): KanbanTask { + // ... existing mapping + return { + id: task._id, + title: task.title, + description: task.description, + priority: mapPriority(task.priority), + assignee: task.assignees?.[0] + ? `${task.assignees[0].emoji || ""} ${task.assignees[0].name}`.trim() + : undefined, + subtasks, + documentCount, // NEW + }; +} +``` + +--- + +## Phase 4: Frontend - Components + +### Step 4.1: Create DocumentsSection Component + +**File:** `apps/web/src/components/kanban/_components/documents-section.tsx` + +```typescript +"use client"; + +import { useQuery } from "convex/react"; +import { api } from "@clawe/backend"; +import type { Id } from "@clawe/backend/dataModel"; +import { FileText, Download, Eye } from "lucide-react"; +import { Button } from "@clawe/ui/components/button"; + +type DocumentsSectionProps = { + taskId: string; + onViewDocument: (doc: Document) => void; +}; + +type Document = { + _id: string; + title: string; + type: string; + path?: string; + fileUrl?: string | null; + creator?: { name: string; emoji?: string } | null; + createdAt: number; +}; + +export const DocumentsSection = ({ taskId, onViewDocument }: DocumentsSectionProps) => { + const documents = useQuery(api.documents.getForTask, { + taskId: taskId as Id<"tasks">, + }); + + // Filter to only show deliverables + const deliverables = documents?.filter((d) => d.type === "deliverable") ?? []; + + if (deliverables.length === 0) { + return null; + } + + return ( +
+

+ Documents ({deliverables.length}) +

+
    + {deliverables.map((doc) => ( +
  • +
    + + {doc.title} +
    +
    + {doc.fileUrl && ( + <> + + + + )} +
    +
  • + ))} +
+
+ ); +}; +``` + +### Step 4.2: Create DocumentViewerModal Component + +**File:** `apps/web/src/components/kanban/_components/document-viewer-modal.tsx` + +```typescript +"use client"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@clawe/ui/components/dialog"; +import { Button } from "@clawe/ui/components/button"; +import { Download } from "lucide-react"; +import { ScrollArea } from "@clawe/ui/components/scroll-area"; + +type Document = { + _id: string; + title: string; + fileUrl?: string | null; + content?: string; +}; + +type DocumentViewerModalProps = { + document: Document | null; + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export const DocumentViewerModal = ({ + document, + open, + onOpenChange, +}: DocumentViewerModalProps) => { + if (!document) return null; + + return ( + + + + + {document.title} + {document.fileUrl && ( + + )} + + + + + {document.fileUrl ? ( +