feat: inline file/screenshot attachment in triage chat
Some checks failed
Test / frontend-typecheck (push) Waiting to run
Test / frontend-tests (push) Waiting to run
Auto Tag / auto-tag (push) Successful in 4s
Test / rust-fmt-check (push) Successful in 1m11s
Release / build-macos-arm64 (push) Successful in 3m43s
Test / rust-clippy (push) Successful in 7m10s
Release / build-linux-amd64 (push) Has been cancelled
Release / build-windows-amd64 (push) Has been cancelled
Release / build-linux-arm64 (push) Has been cancelled
Test / rust-tests (push) Has been cancelled
Some checks failed
Test / frontend-typecheck (push) Waiting to run
Test / frontend-tests (push) Waiting to run
Auto Tag / auto-tag (push) Successful in 4s
Test / rust-fmt-check (push) Successful in 1m11s
Release / build-macos-arm64 (push) Successful in 3m43s
Test / rust-clippy (push) Successful in 7m10s
Release / build-linux-amd64 (push) Has been cancelled
Release / build-windows-amd64 (push) Has been cancelled
Release / build-linux-arm64 (push) Has been cancelled
Test / rust-tests (push) Has been cancelled
- NewIssue navigates directly to /triage — log upload is never a blocker - ChatWindow: paperclip button opens Tauri file dialog; pending files shown as removable chips above the input; send enabled with files and no text - Triage: uploads selected files via uploadLogFileCmd, reads text content (capped at 8KB), appends file contents to AI message for context while showing only filenames in the chat bubble - Images/binary files are referenced by name with a prompt for the user to describe them
This commit is contained in:
parent
ebf60d17c6
commit
5537b0b042
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useRef, useEffect } from "react";
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
import { Send, Bot, User } from "lucide-react";
|
import { Send, Bot, User, Paperclip, X } from "lucide-react";
|
||||||
import type { TriageMessage } from "@/lib/tauriCommands";
|
import type { TriageMessage } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
interface ChatWindowProps {
|
interface ChatWindowProps {
|
||||||
@ -7,9 +7,12 @@ interface ChatWindowProps {
|
|||||||
onSend: (message: string) => Promise<void>;
|
onSend: (message: string) => Promise<void>;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
pendingFiles?: { name: string }[];
|
||||||
|
onAttach?: () => void;
|
||||||
|
onRemoveFile?: (index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatWindow({ messages, onSend, isLoading, placeholder }: ChatWindowProps) {
|
export function ChatWindow({ messages, onSend, isLoading, placeholder, pendingFiles, onAttach, onRemoveFile }: ChatWindowProps) {
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@ -76,20 +79,48 @@ export function ChatWindow({ messages, onSend, isLoading, placeholder }: ChatWin
|
|||||||
)}
|
)}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t p-4">
|
<div className="border-t p-4 space-y-2">
|
||||||
|
{pendingFiles && pendingFiles.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{pendingFiles.map((f, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs text-secondary-foreground"
|
||||||
|
>
|
||||||
|
<Paperclip className="w-3 h-3" />
|
||||||
|
{f.name}
|
||||||
|
{onRemoveFile && (
|
||||||
|
<button onClick={() => onRemoveFile(i)} className="ml-1 hover:text-destructive">
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
{onAttach && (
|
||||||
|
<button
|
||||||
|
onClick={onAttach}
|
||||||
|
disabled={isLoading}
|
||||||
|
title="Attach log file or screenshot"
|
||||||
|
className="px-3 py-2 rounded-md border border-input bg-background hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Paperclip className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={placeholder ?? "Type your response... (Enter to send, Shift+Enter for new line)"}
|
placeholder={placeholder ?? "Type your response... (Enter to send, Shift+Enter for new line)"}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={!input.trim() || isLoading}
|
disabled={(!input.trim() && (!pendingFiles || pendingFiles.length === 0)) || isLoading}
|
||||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Send className="w-4 h-4" />
|
<Send className="w-4 h-4" />
|
||||||
|
|||||||
@ -53,7 +53,7 @@ export default function NewIssue() {
|
|||||||
try {
|
try {
|
||||||
const issue = await createIssueCmd({ title: title.trim(), domain: selectedDomain, severity });
|
const issue = await createIssueCmd({ title: title.trim(), domain: selectedDomain, severity });
|
||||||
startSession(issue);
|
startSession(issue);
|
||||||
navigate(`/issue/${issue.id}/logs`);
|
navigate(`/issue/${issue.id}/triage`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(String(err));
|
setError(String(err));
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
|
|||||||
@ -1,18 +1,23 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { CheckCircle, ChevronRight } from "lucide-react";
|
import { CheckCircle, ChevronRight } from "lucide-react";
|
||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { readTextFile } from "@tauri-apps/plugin-fs";
|
||||||
import { ChatWindow } from "@/components/ChatWindow";
|
import { ChatWindow } from "@/components/ChatWindow";
|
||||||
import { TriageProgress } from "@/components/TriageProgress";
|
import { TriageProgress } from "@/components/TriageProgress";
|
||||||
import { useSessionStore } from "@/stores/sessionStore";
|
import { useSessionStore } from "@/stores/sessionStore";
|
||||||
import { useSettingsStore } from "@/stores/settingsStore";
|
import { useSettingsStore } from "@/stores/settingsStore";
|
||||||
import { chatMessageCmd, getIssueCmd } from "@/lib/tauriCommands";
|
import { chatMessageCmd, getIssueCmd, uploadLogFileCmd } from "@/lib/tauriCommands";
|
||||||
import type { TriageMessage } from "@/lib/tauriCommands";
|
import type { TriageMessage } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
type PendingFile = { name: string; content: string | null };
|
||||||
|
|
||||||
export default function Triage() {
|
export default function Triage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
||||||
const initialized = useRef(false);
|
const initialized = useRef(false);
|
||||||
|
|
||||||
const { currentIssue, messages, currentWhyLevel, startSession, addMessage, setWhyLevel } =
|
const { currentIssue, messages, currentWhyLevel, startSession, addMessage, setWhyLevel } =
|
||||||
@ -42,6 +47,35 @@ export default function Triage() {
|
|||||||
.catch((e) => setError(String(e)));
|
.catch((e) => setError(String(e)));
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
const handleAttach = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
const selected = await open({
|
||||||
|
multiple: true,
|
||||||
|
filters: [
|
||||||
|
{ name: "Log & Text Files", extensions: ["log", "txt", "json", "xml", "yaml", "yml", "csv", "out", "err"] },
|
||||||
|
{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "bmp", "webp"] },
|
||||||
|
{ name: "All Files", extensions: ["*"] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (!selected) return;
|
||||||
|
const paths = Array.isArray(selected) ? selected : [selected];
|
||||||
|
for (const filePath of paths) {
|
||||||
|
const logFile = await uploadLogFileCmd(id, filePath);
|
||||||
|
let content: string | null = null;
|
||||||
|
try {
|
||||||
|
const raw = await readTextFile(filePath);
|
||||||
|
content = raw.slice(0, 8000); // cap at 8 KB to keep context manageable
|
||||||
|
} catch {
|
||||||
|
// Binary file (image) — include filename only as context
|
||||||
|
}
|
||||||
|
setPendingFiles((prev) => [...prev, { name: logFile.file_name, content }]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(`Attachment failed: ${String(e)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSend = async (message: string) => {
|
const handleSend = async (message: string) => {
|
||||||
if (!id || !currentIssue) return;
|
if (!id || !currentIssue) return;
|
||||||
const provider = getActiveProvider();
|
const provider = getActiveProvider();
|
||||||
@ -53,18 +87,33 @@ export default function Triage() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
// Build AI context: user text + file contents; show only text + filenames in chat UI
|
||||||
|
const fileContext = pendingFiles
|
||||||
|
.map((f) =>
|
||||||
|
f.content
|
||||||
|
? `\n\n--- Attached: ${f.name} ---\n${f.content}`
|
||||||
|
: `\n\n[Image attached: ${f.name} — describe what you see if relevant]`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
const aiMessage = pendingFiles.length > 0 ? `${message}${fileContext}` : message;
|
||||||
|
const displayContent =
|
||||||
|
pendingFiles.length > 0
|
||||||
|
? `${message}${message ? "\n" : ""}📎 ${pendingFiles.map((f) => f.name).join(", ")}`
|
||||||
|
: message;
|
||||||
|
|
||||||
const userMsg: TriageMessage = {
|
const userMsg: TriageMessage = {
|
||||||
id: `user-${Date.now()}`,
|
id: `user-${Date.now()}`,
|
||||||
issue_id: id,
|
issue_id: id,
|
||||||
role: "user",
|
role: "user",
|
||||||
content: message,
|
content: displayContent,
|
||||||
why_level: currentWhyLevel,
|
why_level: currentWhyLevel,
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
};
|
};
|
||||||
addMessage(userMsg);
|
addMessage(userMsg);
|
||||||
|
setPendingFiles([]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await chatMessageCmd(id, message, provider);
|
const response = await chatMessageCmd(id, aiMessage, provider);
|
||||||
const assistantMsg: TriageMessage = {
|
const assistantMsg: TriageMessage = {
|
||||||
id: `asst-${Date.now()}`,
|
id: `asst-${Date.now()}`,
|
||||||
issue_id: id,
|
issue_id: id,
|
||||||
@ -126,6 +175,9 @@ export default function Triage() {
|
|||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
placeholder="Describe the problem or answer the AI's question..."
|
placeholder="Describe the problem or answer the AI's question..."
|
||||||
|
pendingFiles={pendingFiles}
|
||||||
|
onAttach={handleAttach}
|
||||||
|
onRemoveFile={(i) => setPendingFiles((prev) => prev.filter((_, idx) => idx !== i))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user