From b28d57bfc802b92360c854850de2adebdc3a30ff Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 1 Oct 2026 00:06:50 +0200 Subject: [PATCH] feat(giellie): chat UI, approval card, photo/file composer, sidebar rename Co-Authored-By: Claude Sonnet 5.5 --- src/app/(app)/giellie/page.tsx | 85 +++++++ src/components/giellie/approval-card.test.tsx | 38 ++++ src/components/giellie/approval-card.tsx | 46 ++++ src/components/giellie/chat-list.tsx | 41 ++++ src/components/giellie/composer.tsx | 95 ++++++++ src/components/giellie/message-list.tsx | 132 +++++++++++ src/components/giellie/use-giellie.ts | 211 ++++++++++++++++++ src/components/sidebar.tsx | 5 +- src/lib/giellie-image.ts | 26 +++ 9 files changed, 677 insertions(+), 2 deletions(-) create mode 100644 src/app/(app)/giellie/page.tsx create mode 100644 src/components/giellie/approval-card.test.tsx create mode 100644 src/components/giellie/approval-card.tsx create mode 100644 src/components/giellie/chat-list.tsx create mode 100644 src/components/giellie/composer.tsx create mode 100644 src/components/giellie/message-list.tsx create mode 100644 src/components/giellie/use-giellie.ts create mode 100644 src/lib/giellie-image.ts diff --git a/src/app/(app)/giellie/page.tsx b/src/app/(app)/giellie/page.tsx new file mode 100644 index 0000000..123bc31 --- /dev/null +++ b/src/app/(app)/giellie/page.tsx @@ -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 ( +
+ + +
+
+ + +

Giellie Agent

+ +
+ + {notReady ? ( +
+ {g.ready?.reason}{" "} + + Open AI Settings + +
+ ) : ( + <> + {!g.chat || g.chat.messages.length === 0 ? ( +
+ Ask about any order, quote, customer or supplier — or paste a price list, upload a spreadsheet, or take a photo. +
+ ) : ( + + )} + {g.error &&

{g.error}

} + {g.pending && ( +
+ void g.decide(approve, trust)} /> +
+ )} + void g.send(text, files, trust)} /> + + )} +
+
+ ); +} diff --git a/src/components/giellie/approval-card.test.tsx b/src/components/giellie/approval-card.test.tsx new file mode 100644 index 0000000..8e8a9c7 --- /dev/null +++ b/src/components/giellie/approval-card.test.tsx @@ -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( {}} />); + 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(); + 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( {}} />); + expect((screen.getByRole("button", { name: /approve/i }) as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByRole("button", { name: /reject/i }) as HTMLButtonElement).disabled).toBe(true); + }); +}); diff --git a/src/components/giellie/approval-card.tsx b/src/components/giellie/approval-card.tsx new file mode 100644 index 0000000..6f8866e --- /dev/null +++ b/src/components/giellie/approval-card.tsx @@ -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 ( +
+

+ Giellie wants to make {pending.tool_uses.length === 1 ? "this change" : `${pending.tool_uses.length} changes`}: +

+
    + {pending.tool_uses.map((tu) => ( +
  • +
    + {tu.name} + {tu.confirmAlways && ( + + Sends an email + + )} +
    +
    +              {JSON.stringify(tu.input, null, 2)}
    +            
    +
  • + ))} +
+
+ + +
+
+ ); +} diff --git a/src/components/giellie/chat-list.tsx b/src/components/giellie/chat-list.tsx new file mode 100644 index 0000000..90d79e2 --- /dev/null +++ b/src/components/giellie/chat-list.tsx @@ -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 ( +
+
+ +
+
+ {chats.length === 0 &&

No chats yet.

} + {chats.map((c) => ( + + ))} +
+
+ ); +} diff --git a/src/components/giellie/composer.tsx b/src/components/giellie/composer.tsx new file mode 100644 index 0000000..c0fe9bd --- /dev/null +++ b/src/components/giellie/composer.tsx @@ -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([]); + const fileRef = useRef(null); + const cameraRef = useRef(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 ( +
+ {files.length > 0 && ( +
+ {files.map((f, i) => ( + + {f.name} + + + ))} +
+ )} +
+ { + void addFiles(e.target.files); + e.target.value = ""; + }} + /> + { + void addFiles(e.target.files); + e.target.value = ""; + }} + /> + + +