Skip to content
Open
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
2 changes: 0 additions & 2 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ jobs:
test-api:
name: API tests
needs: changes
if: needs.changes.outputs.api == 'true'
runs-on: ubuntu-24.04-arm
timeout-minutes: 10
steps:
Expand Down Expand Up @@ -132,7 +131,6 @@ jobs:
helm-lint:
name: Helm chart lint
needs: changes
if: needs.changes.outputs.helm == 'true'
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
Expand Down
151 changes: 148 additions & 3 deletions frontend/components/ChatArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,14 @@ import { BookOpenText } from '@phosphor-icons/react/dist/ssr/BookOpenText';
import { Table } from '@phosphor-icons/react/dist/ssr/Table';
import { Compass } from '@phosphor-icons/react/dist/ssr/Compass';
import { Sparkle } from '@phosphor-icons/react/dist/ssr/Sparkle';
import { FileArrowUp } from '@phosphor-icons/react/dist/ssr/FileArrowUp';
import { SpinnerGap } from '@phosphor-icons/react/dist/ssr/SpinnerGap';
import { WarningCircle } from '@phosphor-icons/react/dist/ssr/WarningCircle';
import { ListBullets } from '@phosphor-icons/react/dist/ssr/ListBullets';
import { Question } from '@phosphor-icons/react/dist/ssr/Question';
import { ThreadPrimitive } from '@assistant-ui/react';
import type { ChatAttachment, Message, ProjectDocument } from '@/lib/types';
import { PROJECT_DOC_ACCEPT, PROJECT_DOC_MAX_MB, PROJECT_DOC_SUPPORTED_LABEL } from '@/lib/upload';
import MessageBubble from './MessageBubble';
import ChatInput from './ChatInput';

Expand Down Expand Up @@ -40,6 +46,31 @@ const SUGGESTIONS: SuggestionCard[] = [
},
];

// Shown once a project has at least one indexed document — orients the user
// toward their first grounded answer instead of generic tool prompts.
const DOC_SUGGESTIONS: SuggestionCard[] = [
{
label: 'Summarize the key points',
query: 'Summarize the key points from my documents.',
icon: <ListBullets size={20} className="text-emerald-400" aria-hidden="true" />,
},
{
label: 'Ask a question about your documents',
query: 'Based on my documents, ',
icon: <Question size={20} className="text-emerald-400" aria-hidden="true" />,
},
{
label: 'Search the knowledge base',
query: 'Search the knowledge base for information about ',
icon: <BookOpenText size={20} className="text-emerald-400" aria-hidden="true" />,
},
{
label: 'Browse the web',
query: 'Browse the web and find information about ',
icon: <Compass size={20} className="text-emerald-400" aria-hidden="true" />,
},
];

// RunaxAI logo: interconnected network graph with central pulse spark.
// Emerald palette to match locked brand accent (skill §3 Rule 2).
export function RunaxLogo({ size = 40 }: { size?: number }) {
Expand Down Expand Up @@ -93,6 +124,9 @@ interface ChatAreaProps {
onStop?: () => void;
projectId?: string;
projectDocuments?: ProjectDocument[];
onUploadFile?: (file: File) => void;
isUploading?: boolean;
uploadError?: string | null;
attachments?: ChatAttachment[];
onAttachmentsChange?: (next: ChatAttachment[]) => void;
sessionFileCount?: number;
Expand All @@ -110,12 +144,17 @@ export default function ChatArea({
onStop,
projectId,
projectDocuments,
onUploadFile,
isUploading = false,
uploadError,
attachments,
onAttachmentsChange,
sessionFileCount = 0,
sessionBytes = 0,
}: ChatAreaProps) {
const inputRef = useRef<HTMLDivElement>(null);
const heroFileInputRef = useRef<HTMLInputElement>(null);
const [heroDragOver, setHeroDragOver] = useState(false);

// Keyboard shortcuts: / to focus input, Escape to blur
useEffect(() => {
Expand Down Expand Up @@ -227,6 +266,25 @@ export default function ChatArea({

const isEmpty = messages.length === 0;

// Project-aware first-run state. In a project context we guide the user from
// an empty project -> first upload -> first grounded answer.
const isProjectContext = Boolean(projectId);
const docs = projectDocuments ?? [];
const readyDocs = docs.filter((d) => d.status === 'ready');
const indexingDocs = docs.filter(
(d) => d.status === 'processing' || d.status === 'uploading'
);
const needsFirstDocument =
isProjectContext && Boolean(onUploadFile) && readyDocs.length === 0;
const suggestions = readyDocs.length > 0 ? DOC_SUGGESTIONS : SUGGESTIONS;

const handleHeroFile = useCallback(
(file: File | undefined) => {
if (file && onUploadFile) onUploadFile(file);
},
[onUploadFile]
);

return (
<div className="flex flex-col flex-1 min-h-0 relative">
{/* Messages area */}
Expand All @@ -237,7 +295,90 @@ export default function ChatArea({
aria-live="polite"
aria-label="Chat messages"
>
{isEmpty ? (
{isEmpty && needsFirstDocument ? (
/* First-run: empty project — guide the user to upload their first doc */
<div className="flex flex-col items-center justify-center min-h-full px-4 py-10">
<div className="w-full max-w-xl flex flex-col items-center gap-6">
<div className="flex flex-col items-center gap-3 text-center">
<div className="w-14 h-14 rounded-2xl bg-linear-to-br from-emerald-600/20 to-emerald-600/20 border border-emerald-500/20 flex items-center justify-center">
<RunaxLogo size={36} />
</div>
<div>
<h1 className="text-2xl font-semibold text-zinc-100 tracking-tight mb-1.5">
{indexingDocs.length > 0
? 'Indexing your document…'
: 'Add a document to get started'}
</h1>
<p className="text-sm text-zinc-500 max-w-sm leading-relaxed">
{indexingDocs.length > 0
? 'This usually takes a few seconds. You can start chatting as soon as it’s ready.'
: `Upload a ${PROJECT_DOC_SUPPORTED_LABEL} file and RunaxAI will answer questions grounded in it.`}
</p>
</div>
</div>

{/* Upload dropzone */}
<input
ref={heroFileInputRef}
type="file"
onChange={(e) => {
handleHeroFile(e.target.files?.[0]);
e.target.value = '';
}}
accept={PROJECT_DOC_ACCEPT}
className="hidden"
/>
<button
type="button"
onClick={() => heroFileInputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
setHeroDragOver(true);
}}
onDragLeave={() => setHeroDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setHeroDragOver(false);
handleHeroFile(e.dataTransfer.files?.[0]);
}}
disabled={isUploading}
className={`w-full flex flex-col items-center gap-2 px-6 py-8 rounded-2xl border border-dashed transition-all duration-150 ${
heroDragOver
? 'border-emerald-400/60 bg-emerald-500/10'
: 'border-white/12 hover:border-white/25 hover:bg-white/3'
} ${isUploading ? 'pointer-events-none opacity-70' : ''}`}
>
{isUploading || indexingDocs.length > 0 ? (
<SpinnerGap size={26} className="text-emerald-400/80 animate-spin" aria-hidden="true" />
) : (
<FileArrowUp size={26} className="text-emerald-400/70" aria-hidden="true" />
)}
<span className="text-sm font-medium text-zinc-300">
{isUploading
? 'Uploading…'
: indexingDocs.length > 0
? 'Indexing…'
: 'Drop a file here, or click to browse'}
</span>
<span className="text-[11px] text-zinc-600">
{PROJECT_DOC_SUPPORTED_LABEL} · up to {PROJECT_DOC_MAX_MB} MB
</span>
</button>

{uploadError && (
<div className="w-full flex items-start gap-2 px-4 py-2.5 rounded-xl border border-red-500/25 bg-red-500/10">
<WarningCircle size={16} className="text-red-400 shrink-0 mt-0.5" aria-hidden="true" />
<p className="flex-1 text-xs leading-relaxed text-red-300">{uploadError}</p>
</div>
)}

<p className="text-[11px] text-zinc-600 text-center max-w-sm">
Prefer to explore first? You can also just start chatting below — RunaxAI can
search the web and query databases without a document.
</p>
</div>
</div>
) : isEmpty ? (
/* Welcome / empty state */
<div className="flex flex-col items-center justify-center min-h-full px-4 py-10">
<div className="w-full max-w-xl flex flex-col items-center gap-6">
Expand All @@ -251,14 +392,18 @@ export default function ChatArea({
RunaxAI
</h1>
<p className="text-sm text-zinc-500 max-w-sm leading-relaxed">
AI with full tool access — document search, database queries, and web browsing
{readyDocs.length > 0
? `Ask anything about your ${readyDocs.length} indexed document${
readyDocs.length === 1 ? '' : 's'
} — or use web search and database queries.`
: 'AI with full tool access — document search, database queries, and web browsing'}
</p>
</div>
</div>

{/* Suggestion cards */}
<div className="grid grid-cols-2 gap-2.5 w-full">
{SUGGESTIONS.map((card) => (
{suggestions.map((card) => (
<button
key={card.label}
onClick={() => onInputChange(card.query)}
Expand Down
19 changes: 19 additions & 0 deletions frontend/components/ProjectPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
useExternalStoreRuntime,
} from "@assistant-ui/react";
import {convertMessage} from "@/lib/chatRuntime";
import {validateUploadFile} from "@/lib/upload";
import {
appendDataPart,
appendReasoningPart,
Expand Down Expand Up @@ -118,6 +119,7 @@ export default function ProjectPage({
const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [reingestingDocumentId, setReingestingDocumentId] = useState<string | null>(null);
const [streamingMessageId, setStreamingMessageId] = useState<string | null>(
null
Expand Down Expand Up @@ -204,6 +206,13 @@ export default function ProjectPage({
const handleUploadFile = useCallback(
async (file: File) => {
if (!project) return;
// Pre-flight validation mirrors the backend so failures surface instantly.
const validationError = validateUploadFile(file);
if (validationError) {
setUploadError(validationError);
return;
}
setUploadError(null);
setIsUploading(true);
try {
const doc = await uploadDocument(projectId, file);
Expand All @@ -212,13 +221,18 @@ export default function ProjectPage({
);
} catch (err) {
console.error("Upload failed:", err);
setUploadError(
err instanceof Error ? err.message : "Upload failed. Please try again."
);
} finally {
setIsUploading(false);
}
},
[project, projectId]
);

const handleDismissUploadError = useCallback(() => setUploadError(null), []);

const handleDeleteDocument = useCallback(
async (docId: string) => {
try {
Expand Down Expand Up @@ -929,6 +943,8 @@ export default function ProjectPage({
onReingestDocument={handleReingestDocument}
onDeleteDocument={handleDeleteDocument}
isUploading={isUploading}
uploadError={uploadError}
onDismissUploadError={handleDismissUploadError}
reingestingDocumentId={reingestingDocumentId}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
Expand Down Expand Up @@ -1003,6 +1019,9 @@ export default function ProjectPage({
onStop={handleStop}
projectId={projectId}
projectDocuments={project.documents}
onUploadFile={handleUploadFile}
isUploading={isUploading}
uploadError={uploadError}
/>
</AssistantRuntimeProvider>
</div>
Expand Down
45 changes: 38 additions & 7 deletions frontend/components/ProjectSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ import { Lightning } from '@phosphor-icons/react/dist/ssr/Lightning';
import { ChatTeardropDots } from '@phosphor-icons/react/dist/ssr/ChatTeardropDots';
import { MagnifyingGlass } from '@phosphor-icons/react/dist/ssr/MagnifyingGlass';
import { X } from '@phosphor-icons/react/dist/ssr/X';
import { WarningCircle } from '@phosphor-icons/react/dist/ssr/WarningCircle';
import { RunaxLogo } from './ChatArea';
import SidebarAccountFooter from './SidebarAccountFooter';
import { PROJECT_DOC_ACCEPT, PROJECT_DOC_MAX_MB, PROJECT_DOC_SUPPORTED_LABEL } from '@/lib/upload';
import type { Project, AgentInfo, Session, ProjectSearchResult, User } from '@/lib/types';

// Agents differentiate by glyph, not colour (skill §3 Rule 2 — Lila Ban).
Expand Down Expand Up @@ -79,6 +81,8 @@ interface ProjectSidebarProps {
onReingestDocument: (docId: string, file: File) => void;
onDeleteDocument: (docId: string) => void;
isUploading: boolean;
uploadError?: string | null;
onDismissUploadError?: () => void;
reingestingDocumentId: string | null;
searchQuery: string;
onSearchQueryChange: (value: string) => void;
Expand All @@ -104,6 +108,8 @@ export default function ProjectSidebar({
onReingestDocument,
onDeleteDocument,
isUploading,
uploadError,
onDismissUploadError,
reingestingDocumentId,
searchQuery,
onSearchQueryChange,
Expand Down Expand Up @@ -375,14 +381,14 @@ export default function ProjectSidebar({
ref={fileInputRef}
type="file"
onChange={handleFileChange}
accept=".pdf,.txt,.md,.csv,.docx"
accept={PROJECT_DOC_ACCEPT}
className="hidden"
/>
<input
ref={replaceFileInputRef}
type="file"
onChange={handleReplaceFileChange}
accept=".pdf,.txt,.md,.csv,.docx"
accept={PROJECT_DOC_ACCEPT}
className="hidden"
/>
{isUploading ? (
Expand All @@ -391,11 +397,28 @@ export default function ProjectSidebar({
<FileArrowUp size={20} className="text-zinc-500" />
)}
<span className="text-xs text-zinc-500">
{isUploading ? 'Uploading…' : 'Drop a PDF, CSV, MD, or DOCX'}
{isUploading ? 'Uploading…' : `Drop a ${PROJECT_DOC_SUPPORTED_LABEL} file`}
</span>
<span className="text-[10px] text-zinc-600">Up to 25 MB per file</span>
<span className="text-[10px] text-zinc-600">Up to {PROJECT_DOC_MAX_MB} MB per file</span>
</div>

{/* Upload error */}
{uploadError && (
<div className="mb-2 flex items-start gap-2 px-3 py-2 rounded-lg border border-red-500/25 bg-red-500/10">
<WarningCircle size={15} className="text-red-400 shrink-0 mt-0.5" aria-hidden="true" />
<p className="flex-1 text-[11px] leading-relaxed text-red-300">{uploadError}</p>
{onDismissUploadError && (
<button
onClick={onDismissUploadError}
aria-label="Dismiss upload error"
className="p-0.5 rounded text-red-400/70 hover:text-red-300 transition-colors duration-100 shrink-0"
>
<X size={12} aria-hidden="true" />
</button>
)}
</div>
)}

{/* Document list */}
<ul className="flex flex-col gap-0.5">
{project.documents.map((doc) => (
Expand All @@ -404,9 +427,17 @@ export default function ProjectSidebar({
<FileTypeIcon type={doc.fileType} />
<div className="flex-1 min-w-0">
<p className="text-sm text-zinc-300 truncate">{doc.filename}</p>
<p className="text-[11px] text-zinc-600">
{formatFileSize(doc.fileSize)}
{doc.status === 'ready' && ` \u00B7 ${doc.chunkCount} chunks`}
<p
className={`text-[11px] truncate ${
doc.status === 'failed' ? 'text-red-400/80' : 'text-zinc-600'
}`}
>
{doc.status === 'ready' &&
`${formatFileSize(doc.fileSize)} \u00B7 ${doc.chunkCount} chunks`}
{(doc.status === 'uploading' || doc.status === 'processing') &&
'Indexing\u2026 ready to chat shortly'}
{doc.status === 'failed' &&
(doc.errorMessage || 'Indexing failed \u2014 try replacing the file')}
</p>
</div>
<StatusBadge status={doc.status} />
Expand Down
Loading