feat(giellie): chat UI, approval card, photo/file composer, sidebar rename

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-01 00:06:50 +02:00
parent cce2570dcd
commit b28d57bfc8
9 changed files with 677 additions and 2 deletions

View file

@ -0,0 +1,85 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { Bot, Menu } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { ChatList } from "@/components/giellie/chat-list";
import { MessageList } from "@/components/giellie/message-list";
import { ApprovalCard } from "@/components/giellie/approval-card";
import { Composer } from "@/components/giellie/composer";
import { useGielie } from "@/components/giellie/use-giellie";
export default function GielliePage() {
const g = useGielie();
// Per-session only; resets on reload by design. Never covers email sends.
const [trust, setTrust] = useState(false);
const [showList, setShowList] = useState(false);
const notReady = g.ready && !g.ready.ready;
return (
<div className="relative flex h-[calc(100vh-4rem)] overflow-hidden rounded-lg border bg-background">
<aside
className={
"w-64 shrink-0 border-r bg-muted/30 md:block " +
(showList ? "block absolute z-20 h-[calc(100vh-4rem)] bg-background shadow-lg" : "hidden")
}
>
<ChatList
chats={g.chats}
activeId={g.chat?.id ?? null}
onOpen={(id) => {
setShowList(false);
void g.openChat(id);
}}
onNew={() => {
setShowList(false);
g.newChat();
}}
/>
</aside>
<section className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center gap-2 border-b px-3 py-2">
<Button variant="ghost" size="icon" className="md:hidden" aria-label="Chats" onClick={() => setShowList((s) => !s)}>
<Menu className="h-5 w-5" />
</Button>
<Bot className="h-5 w-5" />
<h1 className="text-base font-semibold font-heading-theme">Giellie Agent</h1>
<label className="ml-auto flex items-center gap-2 text-xs text-muted-foreground" title="Auto-approve ordinary changes in this session. Emails always ask.">
Trust this chat
<Switch checked={trust} onCheckedChange={setTrust} />
</label>
</header>
{notReady ? (
<div className="m-4 rounded-md border border-amber-400/60 bg-amber-50 p-3 text-sm dark:bg-amber-950/30">
{g.ready?.reason}{" "}
<Link href="/settings/ai" className="underline">
Open AI Settings
</Link>
</div>
) : (
<>
{!g.chat || g.chat.messages.length === 0 ? (
<div className="flex flex-1 items-center justify-center p-6 text-center text-sm text-muted-foreground">
Ask about any order, quote, customer or supplier — or paste a price list, upload a spreadsheet, or take a photo.
</div>
) : (
<MessageList messages={g.chat.messages} liveText={g.liveText} activity={g.activity} busy={g.busy} />
)}
{g.error && <p className="px-4 pb-2 text-sm text-red-600">{g.error}</p>}
{g.pending && (
<div className="px-4 pb-3">
<ApprovalCard pending={g.pending} busy={g.busy} onDecide={(approve) => void g.decide(approve, trust)} />
</div>
)}
<Composer disabled={g.busy || !!g.pending} onSend={(text, files) => void g.send(text, files, trust)} />
</>
)}
</section>
</div>
);
}

View file

@ -0,0 +1,38 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ApprovalCard } from "./approval-card";
import type { PendingAction } from "@/lib/giellie-types";
const pending: PendingAction = {
tool_uses: [
{ id: "1", name: "orders.update", input: { id: "abc", notes: "rush" }, confirmAlways: false },
{ id: "2", name: "quotes.send_email", input: { id: "q1" }, confirmAlways: true },
],
completed: [],
order: ["1", "2"],
};
describe("ApprovalCard", () => {
it("lists each action with its arguments and flags sends", () => {
render(<ApprovalCard pending={pending} busy={false} onDecide={() => {}} />);
expect(screen.getByText("orders.update")).toBeTruthy();
expect(screen.getByText(/"notes": "rush"/)).toBeTruthy();
expect(screen.getByText("quotes.send_email")).toBeTruthy();
expect(screen.getByText(/sends an email/i)).toBeTruthy();
});
it("calls onDecide(true) on Approve and onDecide(false) on Reject", () => {
const onDecide = vi.fn();
render(<ApprovalCard pending={pending} busy={false} onDecide={onDecide} />);
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
fireEvent.click(screen.getByRole("button", { name: /reject/i }));
expect(onDecide).toHaveBeenNthCalledWith(1, true);
expect(onDecide).toHaveBeenNthCalledWith(2, false);
});
it("disables both buttons while busy", () => {
render(<ApprovalCard pending={pending} busy onDecide={() => {}} />);
expect((screen.getByRole("button", { name: /approve/i }) as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByRole("button", { name: /reject/i }) as HTMLButtonElement).disabled).toBe(true);
});
});

View file

@ -0,0 +1,46 @@
"use client";
import { Check, Mail, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { PendingAction } from "@/lib/giellie-types";
interface Props {
pending: PendingAction;
busy: boolean;
onDecide: (approve: boolean) => void;
}
export function ApprovalCard({ pending, busy, onDecide }: Props) {
return (
<div className="rounded-lg border border-amber-400/60 bg-amber-50 dark:bg-amber-950/30 p-3 space-y-3">
<p className="text-sm font-medium">
Giellie wants to make {pending.tool_uses.length === 1 ? "this change" : `${pending.tool_uses.length} changes`}:
</p>
<ul className="space-y-2">
{pending.tool_uses.map((tu) => (
<li key={tu.id} className="rounded-md border bg-background p-2">
<div className="flex items-center gap-2 text-sm font-mono font-semibold">
<span>{tu.name}</span>
{tu.confirmAlways && (
<span className="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-[11px] font-sans font-medium text-red-800 dark:bg-red-950 dark:text-red-200">
<Mail className="h-3 w-3" /> Sends an email
</span>
)}
</div>
<pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap break-words text-xs text-muted-foreground">
{JSON.stringify(tu.input, null, 2)}
</pre>
</li>
))}
</ul>
<div className="flex gap-2">
<Button size="sm" disabled={busy} onClick={() => onDecide(true)}>
<Check className="mr-1 h-4 w-4" /> Approve
</Button>
<Button size="sm" variant="outline" disabled={busy} onClick={() => onDecide(false)}>
<X className="mr-1 h-4 w-4" /> Reject
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,41 @@
"use client";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { GielieChatSummary } from "@/lib/giellie-types";
interface Props {
chats: GielieChatSummary[];
activeId: string | null;
onOpen: (id: string) => void;
onNew: () => void;
}
export function ChatList({ chats, activeId, onOpen, onNew }: Props) {
return (
<div className="flex h-full flex-col">
<div className="p-3">
<Button className="w-full" variant="outline" onClick={onNew}>
<Plus className="mr-1 h-4 w-4" /> New chat
</Button>
</div>
<div className="flex-1 space-y-0.5 overflow-y-auto px-2 pb-2">
{chats.length === 0 && <p className="px-2 py-4 text-xs text-muted-foreground">No chats yet.</p>}
{chats.map((c) => (
<button
key={c.id}
type="button"
onClick={() => onOpen(c.id)}
className={cn(
"w-full truncate rounded-md px-2 py-1.5 text-left text-sm hover:bg-muted",
c.id === activeId && "bg-muted font-medium",
)}
>
{c.title || "New chat"}
</button>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,95 @@
"use client";
import { useRef, useState } from "react";
import { Camera, Paperclip, Send, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { downscaleImage } from "@/lib/giellie-image";
interface Props {
disabled: boolean;
onSend: (text: string, files: File[]) => void;
}
export function Composer({ disabled, onSend }: Props) {
const [text, setText] = useState("");
const [files, setFiles] = useState<File[]>([]);
const fileRef = useRef<HTMLInputElement>(null);
const cameraRef = useRef<HTMLInputElement>(null);
async function addFiles(list: FileList | null) {
if (!list) return;
const prepared = await Promise.all(Array.from(list).map((f) => downscaleImage(f)));
setFiles((prev) => [...prev, ...prepared].slice(0, 10));
}
function submit() {
if (disabled || (!text.trim() && files.length === 0)) return;
onSend(text, files);
setText("");
setFiles([]);
}
return (
<div className="border-t bg-background p-3 space-y-2">
{files.length > 0 && (
<div className="flex flex-wrap gap-2">
{files.map((f, i) => (
<span key={i} className="inline-flex items-center gap-1 rounded-full border bg-muted px-2 py-0.5 text-xs">
{f.name}
<button type="button" aria-label={`Remove ${f.name}`} onClick={() => setFiles((p) => p.filter((_, j) => j !== i))}>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
<div className="flex items-end gap-2">
<input
ref={fileRef}
type="file"
multiple
hidden
accept="image/*,.pdf,.csv,.xlsx,.xls,.docx,.txt"
onChange={(e) => {
void addFiles(e.target.files);
e.target.value = "";
}}
/>
<input
ref={cameraRef}
type="file"
hidden
accept="image/*"
capture="environment"
onChange={(e) => {
void addFiles(e.target.files);
e.target.value = "";
}}
/>
<Button type="button" variant="ghost" size="icon" aria-label="Attach files" onClick={() => fileRef.current?.click()}>
<Paperclip className="h-5 w-5" />
</Button>
<Button type="button" variant="ghost" size="icon" aria-label="Take a photo" onClick={() => cameraRef.current?.click()}>
<Camera className="h-5 w-5" />
</Button>
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Ask Giellie anything, or paste a price list…"
className="min-h-[44px] max-h-40 flex-1 resize-none"
rows={1}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
/>
<Button type="button" size="icon" aria-label="Send" disabled={disabled} onClick={submit}>
<Send className="h-5 w-5" />
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,132 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import { Loader2, Paperclip, Wrench } from "lucide-react";
import { Markdown } from "@/components/markdown";
import type { Block, StoredMessage } from "@/lib/giellie-types";
import type { ActivityItem } from "./use-giellie";
interface Props {
messages: StoredMessage[];
liveText: string;
activity: ActivityItem[];
busy: boolean;
}
/** Extracted-file text is sent to the model but hidden from the bubble. */
const isExtractedFile = (b: Block) =>
b.type === "text" && typeof b.text === "string" && b.text.startsWith("--- File: ");
function ToolLine({
name,
input,
result,
isError,
running,
}: {
name: string;
input: unknown;
result?: string;
isError?: boolean;
running?: boolean;
}) {
return (
<details className="rounded-md border bg-muted/40 px-2 py-1 text-xs">
<summary className="flex cursor-pointer items-center gap-1.5 text-muted-foreground">
{running ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wrench className="h-3 w-3" />}
<span className="font-mono">{name}</span>
{isError && <span className="text-red-600">failed</span>}
</summary>
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap break-words">{JSON.stringify(input, null, 2)}</pre>
{result !== undefined && (
<pre className="mt-1 max-h-60 overflow-auto whitespace-pre-wrap break-words border-t pt-1">
{result.length > 2000 ? result.slice(0, 2000) + "…" : result}
</pre>
)}
</details>
);
}
export function MessageList({ messages, liveText, activity, busy }: Props) {
const endRef = useRef<HTMLDivElement>(null);
const results = useMemo(() => {
const map = new Map<string, Block>();
for (const m of messages) for (const b of m.content) if (b.type === "tool_result") map.set(String(b.tool_use_id), b);
return map;
}, [messages]);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [messages, liveText, activity]);
return (
<div className="flex-1 space-y-3 overflow-y-auto p-4">
{messages.map((m, i) => {
if (m.role === "user") {
if (m.content.every((b) => b.type === "tool_result")) return null;
return (
<div key={i} className="flex justify-end">
<div className="max-w-[85%] space-y-1 rounded-lg bg-primary px-3 py-2 text-sm text-primary-foreground">
{m.content
.filter((b) => b.type === "attachment")
.map((b, j) => (
<div key={j} className="flex items-center gap-1 text-xs opacity-90">
<Paperclip className="h-3 w-3" /> {String(b.filename)}
</div>
))}
{m.content
.filter((b) => b.type === "text" && !isExtractedFile(b))
.map((b, j) => (
<p key={j} className="whitespace-pre-wrap">{String(b.text)}</p>
))}
</div>
</div>
);
}
return (
<div key={i} className="max-w-[90%] space-y-2">
{m.content.map((b, j) => {
if (b.type === "text" && String(b.text).trim()) {
return (
<div key={j} className="rounded-lg border bg-background px-3 py-2 text-sm">
<Markdown>{String(b.text)}</Markdown>
</div>
);
}
if (b.type === "tool_use") {
const r = results.get(String(b.id));
return (
<ToolLine
key={j}
name={String(b.name).replace(/__/g, ".")}
input={b.input}
result={r ? String(r.content) : undefined}
isError={!!r?.is_error}
/>
);
}
return null;
})}
</div>
);
})}
{activity.map((a) => (
<div key={a.id} className="max-w-[90%]">
<ToolLine name={a.name} input={a.input} result={a.done ? a.preview : undefined} isError={a.isError} running={!a.done} />
</div>
))}
{liveText && (
<div className="max-w-[90%] rounded-lg border bg-background px-3 py-2 text-sm">
<Markdown>{liveText}</Markdown>
</div>
)}
{busy && !liveText && activity.every((a) => a.done) && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> Giellie is thinking…
</div>
)}
<div ref={endRef} />
</div>
);
}

View file

@ -0,0 +1,211 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import getPocketBase from "@/lib/pocketbase";
import { createEventParser, type GielieEvent } from "@/lib/giellie-sse";
import type { GielieChat, GielieChatSummary, PendingAction } from "@/lib/giellie-types";
export interface ActivityItem {
id: string;
name: string;
input: Record<string, unknown>;
done: boolean;
isError: boolean;
preview: string;
}
function authHeaders(): Record<string, string> {
const token = getPocketBase().authStore.token;
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function json<T>(res: Response): Promise<T> {
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((body as { error?: string }).error ?? `Request failed (${res.status})`);
return body as T;
}
export function useGielie() {
const [ready, setReady] = useState<{ ready: boolean; reason?: string } | null>(null);
const [chats, setChats] = useState<GielieChatSummary[]>([]);
const [chat, setChat] = useState<GielieChat | null>(null);
const [liveText, setLiveText] = useState("");
const [activity, setActivity] = useState<ActivityItem[]>([]);
const [pending, setPending] = useState<PendingAction | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const chatIdRef = useRef<string | null>(null);
const refreshList = useCallback(async () => {
const { chats } = await json<{ chats: GielieChatSummary[] }>(
await fetch("/api/v1/giellie/chats", { headers: authHeaders() }),
);
setChats(chats);
}, []);
const openChat = useCallback(async (id: string) => {
const { chat } = await json<{ chat: GielieChat }>(
await fetch(`/api/v1/giellie/chats/${id}`, { headers: authHeaders() }),
);
chatIdRef.current = id;
setChat(chat);
setPending(chat.pending_action);
setLiveText("");
setActivity([]);
}, []);
const newChat = useCallback(() => {
chatIdRef.current = null;
setChat(null);
setPending(null);
setLiveText("");
setActivity([]);
setError("");
}, []);
useEffect(() => {
(async () => {
try {
setReady(await json(await fetch("/api/v1/giellie/status", { headers: authHeaders() })));
await refreshList();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load Giellie.");
}
})();
}, [refreshList]);
const apply = useCallback((ev: GielieEvent) => {
switch (ev.type) {
case "text":
setLiveText((t) => t + ev.delta);
break;
case "tool_start":
setActivity((a) => [
...a,
{ id: ev.id, name: ev.name, input: ev.input, done: false, isError: false, preview: "" },
]);
break;
case "tool_result":
setActivity((a) =>
a.map((i) => (i.id === ev.id ? { ...i, done: true, isError: ev.isError, preview: ev.preview } : i)),
);
break;
case "approval_required":
setPending(ev.pending);
break;
case "error":
setError(ev.message);
break;
default:
break;
}
}, []);
const consume = useCallback(
async (res: Response, chatId: string) => {
if (!res.ok || !res.body) {
const body = await res.json().catch(() => ({}));
throw new Error((body as { error?: string }).error ?? `Request failed (${res.status})`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
const parser = createEventParser();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
for (const ev of parser.push(decoder.decode(value, { stream: true }))) apply(ev);
}
// The server transcript is the source of truth once the stream ends.
await openChat(chatId);
await refreshList();
},
[apply, openChat, refreshList],
);
const send = useCallback(
async (text: string, files: File[], trust: boolean) => {
setBusy(true);
setError("");
setLiveText("");
setActivity([]);
try {
let id = chatIdRef.current;
if (!id) {
const created = await json<{ chat: GielieChat }>(
await fetch("/api/v1/giellie/chats", { method: "POST", headers: authHeaders() }),
);
id = created.chat.id;
chatIdRef.current = id;
setChat(created.chat);
}
// Optimistic user bubble until the stream ends and the transcript reloads.
setChat((c) =>
c
? {
...c,
messages: [
...c.messages,
{
role: "user",
ts: new Date().toISOString(),
content: [
...files.map((f) => ({
type: "attachment",
filename: f.name,
kind: f.type.startsWith("image/") ? "image" : "file",
})),
{ type: "text", text },
],
},
],
}
: c,
);
const fd = new FormData();
fd.set("text", text);
fd.set("trust", trust ? "1" : "0");
files.forEach((f) => fd.append("files", f));
await consume(
await fetch(`/api/v1/giellie/chats/${id}/messages`, { method: "POST", headers: authHeaders(), body: fd }),
id,
);
} catch (e) {
setError(e instanceof Error ? e.message : "Send failed.");
if (chatIdRef.current) await openChat(chatIdRef.current).catch(() => {});
} finally {
setBusy(false);
}
},
[consume, openChat],
);
const decide = useCallback(
async (approve: boolean, trust: boolean) => {
const id = chatIdRef.current;
if (!id) return;
setBusy(true);
setError("");
setPending(null);
setLiveText("");
setActivity([]);
try {
await consume(
await fetch(`/api/v1/giellie/chats/${id}/decision`, {
method: "POST",
headers: { ...authHeaders(), "Content-Type": "application/json" },
body: JSON.stringify({ approve, trust }),
}),
id,
);
} catch (e) {
setError(e instanceof Error ? e.message : "Decision failed.");
await openChat(id).catch(() => {});
} finally {
setBusy(false);
}
},
[consume, openChat],
);
return { ready, chats, chat, liveText, activity, pending, busy, error, openChat, newChat, send, decide };
}

View file

@ -9,13 +9,13 @@ import {
Archive,
BarChart3,
BookOpen,
Bot,
Boxes,
Building2,
CheckCircle,
ChevronDown,
ClipboardList,
FileText,
Globe,
LayoutDashboard,
LogOut,
MapPin,
@ -138,7 +138,7 @@ const stockNavItems: NavEntry[] = [
// clearer home.
const otherItems: NavEntry[] = [
{ href: "/repairs", label: "Repairs", icon: Wrench },
{ href: "/webview?src=hermes", label: "Giellie", icon: Globe },
{ href: "/giellie", label: "Giellie Agent", icon: Bot },
// Standard Operating Procedures for the operator roles. Markdown
// source of truth lives in docs/sops/ — the /sops route renders
// it. See src/lib/sops.ts.
@ -479,6 +479,7 @@ const MOBILE_TITLE_MAP: { prefix: string; label: string }[] = [
{ prefix: "/sprint", label: "Sprint" },
{ prefix: "/stock", label: "Stock" },
{ prefix: "/settings", label: "Settings" },
{ prefix: "/giellie", label: "Giellie Agent" },
{ prefix: "/webview", label: "Webview" },
{ prefix: "/dashboard", label: "Dashboard" },
];

26
src/lib/giellie-image.ts Normal file
View file

@ -0,0 +1,26 @@
/**
* Client-side: shrink a phone photo before upload. Camera JPEGs are often
* 5–12 MB; the server caps files at 10 MB and the model doesn't need more
* than ~1600px to read a price list. Non-images pass through untouched.
*/
export async function downscaleImage(file: File, maxPx = 1600, quality = 0.85): Promise<File> {
if (!file.type.startsWith("image/") || file.type === "image/gif") return file;
try {
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, maxPx / Math.max(bitmap.width, bitmap.height));
if (scale === 1 && file.size <= 2 * 1024 * 1024) {
bitmap.close();
return file;
}
const canvas = document.createElement("canvas");
canvas.width = Math.round(bitmap.width * scale);
canvas.height = Math.round(bitmap.height * scale);
canvas.getContext("2d")!.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
bitmap.close();
const blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, "image/jpeg", quality));
if (!blob) return file;
return new File([blob], file.name.replace(/\.[^.]+$/, "") + ".jpg", { type: "image/jpeg" });
} catch {
return file;
}
}