Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@clawe/ui/components/tooltip";
import type { Agent } from "@clawe/backend/types";

type AgentStatus = "idle" | "active" | "blocked";

Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 3 additions & 10 deletions apps/web/src/app/(dashboard)/board/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -69,6 +61,7 @@ function mapTask(task: ConvexTask): KanbanTask {
? `${task.assignees[0].emoji || ""} ${task.assignees[0].name}`.trim()
: undefined,
subtasks,
documentCount: task.documentCount,
};
}

Expand Down
37 changes: 27 additions & 10 deletions apps/web/src/components/chat/chat-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,13 @@ export const ChatMessage = ({ message, className }: ChatMessageProps) => {
if (isContext) {
return (
<div className={cn("flex justify-center px-4 py-2", className)}>
<div className="border-border/50 bg-muted/30 flex max-w-[90%] items-start gap-2 rounded-lg border border-dashed px-4 py-3">
<div className="border-border/50 bg-muted/30 flex max-w-[90%] min-w-0 items-start gap-2 rounded-lg border border-dashed px-4 py-3">
<Info className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<span className="text-muted-foreground mb-1 block text-xs font-medium tracking-wide uppercase">
Context
</span>
<div className="text-muted-foreground prose prose-sm dark:prose-invert max-w-none">
<div className="text-muted-foreground prose prose-sm dark:prose-invert max-w-none break-words">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
Expand Down Expand Up @@ -66,13 +66,28 @@ export const ChatMessage = ({ message, className }: ChatMessageProps) => {
{children}
</strong>
),
code: ({ children, ...props }) => (
<code
className="rounded bg-black/10 px-1 py-0.5 font-mono text-xs dark:bg-white/10"
code: ({ children, className: codeClassName, ...props }) => {
const isInline = !codeClassName;
return isInline ? (
<code
className="rounded bg-black/10 px-1 py-0.5 font-mono text-xs break-all dark:bg-white/10"
{...props}
>
{children}
</code>
) : (
<code className="font-mono text-xs" {...props}>
{children}
</code>
);
},
pre: ({ children, ...props }) => (
<pre
className="my-2 overflow-auto rounded-md bg-black/10 p-2 dark:bg-white/10"
{...props}
>
{children}
</code>
</pre>
),
}}
>
Expand Down Expand Up @@ -108,22 +123,24 @@ export const ChatMessage = ({ message, className }: ChatMessageProps) => {
{/* Message Content */}
<div
className={cn(
"flex max-w-[80%] flex-col gap-1",
"flex max-w-[80%] min-w-0 flex-col gap-1",
isUser ? "items-end" : "items-start",
)}
>
<div
className={cn(
"rounded-2xl px-4 py-2",
"max-w-full min-w-0 rounded-2xl px-4 py-2",
isUser
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground",
)}
>
{isUser ? (
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
<p className="text-sm break-words whitespace-pre-wrap">
{message.content}
</p>
) : (
<div className="prose prose-sm dark:prose-invert max-w-none">
<div className="prose prose-sm dark:prose-invert max-w-none break-words">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
Expand Down
20 changes: 19 additions & 1 deletion apps/web/src/components/chat/chat-messages.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { cn } from "@clawe/ui/lib/utils";
import { Spinner } from "@clawe/ui/components/spinner";
import { ChatMessage } from "./chat-message";
import { ChatEmpty } from "./chat-empty";
import { ChatThinking } from "./chat-thinking";
Expand Down Expand Up @@ -28,7 +29,24 @@ export const ChatMessages = ({
const showThinking =
isStreaming && lastMessage?.role === "assistant" && !lastMessage.content;

if (!hasMessages && !isLoading && !isStreaming) {
// Show loading spinner when fetching history
if (isLoading && !hasMessages) {
return (
<div
className={cn(
"flex min-h-[50vh] items-center justify-center px-4",
className,
)}
>
<div className="text-muted-foreground flex flex-col items-center gap-2">
<Spinner className="h-6 w-6" />
<span className="text-sm">Loading messages...</span>
</div>
</div>
);
}

if (!hasMessages && !isStreaming) {
return <ChatEmpty className={className} />;
}

Expand Down
115 changes: 115 additions & 0 deletions apps/web/src/components/kanban/_components/document-viewer-modal.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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<string>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] w-full flex-col overflow-hidden sm:w-[95vw] sm:max-w-6xl">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center justify-between gap-4 pr-8">
<span className="truncate">{document.title}</span>
{document.fileUrl && (
<Button variant="outline" size="sm" asChild className="shrink-0">
<a href={document.fileUrl} download={document.title}>
<Download className="mr-2 h-4 w-4" />
Download
</a>
</Button>
)}
</DialogTitle>
</DialogHeader>

{isLoading ? (
<div
className={cn(
"flex items-center justify-center rounded border",
VIEWER_HEIGHT,
)}
>
<Spinner className="h-6 w-6" />
</div>
) : content ? (
<div className={cn("overflow-auto rounded border", VIEWER_HEIGHT)}>
<pre className="p-4 text-sm whitespace-pre-wrap">{content}</pre>
</div>
) : (
<div
className={cn(
"flex items-center justify-center rounded border",
VIEWER_HEIGHT,
)}
>
<p className="text-muted-foreground text-center">
No preview available
</p>
</div>
)}
</DialogContent>
</Dialog>
);
};
74 changes: 74 additions & 0 deletions apps/web/src/components/kanban/_components/documents-section.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<h4 className="text-muted-foreground mb-2 text-sm font-medium">
Documents ({deliverables.length})
</h4>
<ul className="space-y-2">
{deliverables.map((doc) => (
<li
key={doc._id}
className="flex items-center justify-between rounded-md border p-2"
>
<div className="flex min-w-0 items-center gap-2">
<FileText className="text-muted-foreground h-4 w-4 shrink-0" />
<span className="truncate text-sm">{doc.title}</span>
</div>
<div className="flex shrink-0 gap-1">
{doc.fileUrl && (
<>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => onViewDocument(doc)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
asChild
>
<a href={doc.fileUrl} download={doc.title}>
<Download className="h-4 w-4" />
</a>
</Button>
</>
)}
</div>
</li>
))}
</ul>
</div>
);
};
Loading