fix(giellie): mobile overlay, dvh height, markdown image block, per-chat trust, keep draft on failure
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b28d57bfc8
commit
d2e33c5eed
10 changed files with 366 additions and 38 deletions
61
src/app/(app)/giellie/page.test.tsx
Normal file
61
src/app/(app)/giellie/page.test.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
|
||||
const newChat = vi.fn();
|
||||
const openChat = vi.fn();
|
||||
|
||||
vi.mock("@/components/giellie/use-giellie", () => ({
|
||||
useGielie: () => ({
|
||||
ready: { ready: true },
|
||||
chats: [
|
||||
{ id: "a", title: "Chat A", updated: "" },
|
||||
{ id: "b", title: "Chat B", updated: "" },
|
||||
],
|
||||
chat: { id: "a", title: "Chat A", updated: "", messages: [], pending_action: null, attachments: [] },
|
||||
liveText: "",
|
||||
activity: [],
|
||||
pending: null,
|
||||
busy: false,
|
||||
error: "",
|
||||
openChat,
|
||||
newChat,
|
||||
send: vi.fn(),
|
||||
decide: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import GielliePage from "./page";
|
||||
|
||||
const toggle = () => screen.getByRole("switch", { name: /trust this chat/i });
|
||||
|
||||
describe("GielliePage trust toggle", () => {
|
||||
it("shows the email caveat as visible text", () => {
|
||||
render(<GielliePage />);
|
||||
expect(screen.getByText(/emails always ask/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("resets trust when another chat is opened", () => {
|
||||
render(<GielliePage />);
|
||||
fireEvent.click(toggle());
|
||||
expect(toggle().getAttribute("aria-checked")).toBe("true");
|
||||
fireEvent.click(screen.getByText("Chat B"));
|
||||
expect(openChat).toHaveBeenCalledWith("b");
|
||||
expect(toggle().getAttribute("aria-checked")).toBe("false");
|
||||
});
|
||||
|
||||
it("resets trust on New chat", () => {
|
||||
render(<GielliePage />);
|
||||
fireEvent.click(toggle());
|
||||
fireEvent.click(screen.getByRole("button", { name: /new chat/i }));
|
||||
expect(newChat).toHaveBeenCalled();
|
||||
expect(toggle().getAttribute("aria-checked")).toBe("false");
|
||||
});
|
||||
|
||||
it("closes the mobile chat list with the close button", () => {
|
||||
render(<GielliePage />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Chats" }));
|
||||
expect(screen.getByRole("button", { name: /close chats/i })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /close chats/i }));
|
||||
expect(screen.queryByRole("button", { name: /close chats/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Bot, Menu } from "lucide-react";
|
||||
import { Bot, Menu, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ChatList } from "@/components/giellie/chat-list";
|
||||
|
|
@ -18,24 +18,47 @@ export default function GielliePage() {
|
|||
const [showList, setShowList] = useState(false);
|
||||
|
||||
const notReady = g.ready && !g.ready.ready;
|
||||
const errorLine = g.error ? <p className="px-4 pb-2 text-sm text-red-600">{g.error}</p> : null;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-[calc(100vh-4rem)] overflow-hidden rounded-lg border bg-background">
|
||||
// Height = viewport minus the (app) layout chrome around {children}:
|
||||
// below lg: 2px brand stripe + 56px (pt-14 mobile top bar) + 20px + 20px (p-5) = 98px
|
||||
// lg+: 2px brand stripe + 32px + 32px (p-8) = 66px
|
||||
// dvh (not vh) so the iOS Safari toolbar doesn't push the composer off-screen.
|
||||
<div className="relative flex h-[calc(100dvh-98px)] overflow-hidden rounded-lg border bg-background lg:h-[calc(100dvh-66px)]">
|
||||
{showList && (
|
||||
<div
|
||||
data-testid="giellie-backdrop"
|
||||
className="fixed inset-0 z-10 bg-black/40 md:hidden"
|
||||
onClick={() => setShowList(false)}
|
||||
/>
|
||||
)}
|
||||
<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")
|
||||
"w-64 shrink-0 border-r bg-muted/30 md:static md:block " +
|
||||
(showList ? "absolute inset-y-0 left-0 z-20 block bg-background shadow-lg" : "hidden")
|
||||
}
|
||||
>
|
||||
{showList && (
|
||||
<div className="flex justify-end border-b p-1 md:hidden">
|
||||
<Button variant="ghost" size="icon" aria-label="Close chats" onClick={() => setShowList(false)}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ChatList
|
||||
chats={g.chats}
|
||||
activeId={g.chat?.id ?? null}
|
||||
disabled={g.busy}
|
||||
onOpen={(id) => {
|
||||
setShowList(false);
|
||||
// Trust is per-chat: switching chats must never carry it over.
|
||||
if (id !== g.chat?.id) setTrust(false);
|
||||
void g.openChat(id);
|
||||
}}
|
||||
onNew={() => {
|
||||
setShowList(false);
|
||||
setTrust(false);
|
||||
g.newChat();
|
||||
}}
|
||||
/>
|
||||
|
|
@ -48,19 +71,25 @@ export default function GielliePage() {
|
|||
</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>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="text-right leading-tight">
|
||||
<span className="block text-xs font-medium">Trust this chat</span>
|
||||
<span className="block text-[10px] text-muted-foreground">auto-approves changes · emails always ask</span>
|
||||
</div>
|
||||
<Switch aria-label="Trust this chat" checked={trust} onCheckedChange={setTrust} />
|
||||
</div>
|
||||
</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>
|
||||
<>
|
||||
<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>
|
||||
{errorLine}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!g.chat || g.chat.messages.length === 0 ? (
|
||||
|
|
@ -70,13 +99,13 @@ export default function GielliePage() {
|
|||
) : (
|
||||
<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>}
|
||||
{errorLine}
|
||||
{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)} />
|
||||
<Composer disabled={g.busy || !!g.pending} onSend={(text, files, onAccepted) => void g.send(text, files, trust, onAccepted)} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -10,13 +10,15 @@ interface Props {
|
|||
activeId: string | null;
|
||||
onOpen: (id: string) => void;
|
||||
onNew: () => void;
|
||||
/** Locks switching while a response is streaming. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ChatList({ chats, activeId, onOpen, onNew }: Props) {
|
||||
export function ChatList({ chats, activeId, onOpen, onNew, disabled }: Props) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="p-3">
|
||||
<Button className="w-full" variant="outline" onClick={onNew}>
|
||||
<Button className="w-full" variant="outline" disabled={disabled} onClick={onNew}>
|
||||
<Plus className="mr-1 h-4 w-4" /> New chat
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -26,9 +28,10 @@ export function ChatList({ chats, activeId, onOpen, onNew }: Props) {
|
|||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onOpen(c.id)}
|
||||
className={cn(
|
||||
"w-full truncate rounded-md px-2 py-1.5 text-left text-sm hover:bg-muted",
|
||||
"w-full truncate rounded-md px-2 py-1.5 text-left text-sm hover:bg-muted disabled:pointer-events-none disabled:opacity-50",
|
||||
c.id === activeId && "bg-muted font-medium",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
28
src/components/giellie/composer.test.tsx
Normal file
28
src/components/giellie/composer.test.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { Composer } from "./composer";
|
||||
|
||||
const box = () => screen.getByPlaceholderText(/ask giellie/i) as HTMLTextAreaElement;
|
||||
|
||||
describe("Composer draft", () => {
|
||||
it("keeps the draft until onAccepted is called", () => {
|
||||
const onSend = vi.fn();
|
||||
render(<Composer disabled={false} onSend={onSend} />);
|
||||
fireEvent.change(box(), { target: { value: "hello" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
||||
expect(onSend).toHaveBeenCalledWith("hello", [], expect.any(Function));
|
||||
expect(box().value).toBe("hello");
|
||||
act(() => onSend.mock.calls[0][2]());
|
||||
expect(box().value).toBe("");
|
||||
});
|
||||
|
||||
it("does not send on Enter during IME composition", () => {
|
||||
const onSend = vi.fn();
|
||||
render(<Composer disabled={false} onSend={onSend} />);
|
||||
fireEvent.change(box(), { target: { value: "にほん" } });
|
||||
fireEvent.keyDown(box(), { key: "Enter", isComposing: true });
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
fireEvent.keyDown(box(), { key: "Enter" });
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -8,7 +8,8 @@ import { downscaleImage } from "@/lib/giellie-image";
|
|||
|
||||
interface Props {
|
||||
disabled: boolean;
|
||||
onSend: (text: string, files: File[]) => void;
|
||||
/** `onAccepted` must be called once the server has accepted the message; the draft is kept until then. */
|
||||
onSend: (text: string, files: File[], onAccepted: () => void) => void;
|
||||
}
|
||||
|
||||
export function Composer({ disabled, onSend }: Props) {
|
||||
|
|
@ -25,9 +26,10 @@ export function Composer({ disabled, onSend }: Props) {
|
|||
|
||||
function submit() {
|
||||
if (disabled || (!text.trim() && files.length === 0)) return;
|
||||
onSend(text, files);
|
||||
setText("");
|
||||
setFiles([]);
|
||||
onSend(text, files, () => {
|
||||
setText("");
|
||||
setFiles([]);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -80,7 +82,7 @@ export function Composer({ disabled, onSend }: Props) {
|
|||
className="min-h-[44px] max-h-40 flex-1 resize-none"
|
||||
rows={1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ export function MessageList({ messages, liveText, activity, busy }: Props) {
|
|||
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>
|
||||
<Markdown allowImages={false}>{String(b.text)}</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -118,7 +118,7 @@ export function MessageList({ messages, liveText, activity, busy }: Props) {
|
|||
))}
|
||||
{liveText && (
|
||||
<div className="max-w-[90%] rounded-lg border bg-background px-3 py-2 text-sm">
|
||||
<Markdown>{liveText}</Markdown>
|
||||
<Markdown allowImages={false}>{liveText}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
{busy && !liveText && activity.every((a) => a.done) && (
|
||||
|
|
|
|||
143
src/components/giellie/use-giellie.test.tsx
Normal file
143
src/components/giellie/use-giellie.test.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import type { GielieChat, PendingAction } from "@/lib/giellie-types";
|
||||
|
||||
vi.mock("@/lib/pocketbase", () => ({
|
||||
default: () => ({ authStore: { token: "tok" } }),
|
||||
}));
|
||||
|
||||
import { useGielie } from "./use-giellie";
|
||||
|
||||
const pendingAction: PendingAction = {
|
||||
tool_uses: [{ id: "t1", name: "orders.update", input: { id: "a" }, confirmAlways: false }],
|
||||
completed: [],
|
||||
order: ["t1"],
|
||||
};
|
||||
|
||||
function chatFixture(over: Partial<GielieChat> = {}): GielieChat {
|
||||
return { id: "c1", title: "", updated: "", messages: [], pending_action: null, attachments: [], ...over };
|
||||
}
|
||||
|
||||
function jsonRes(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
function sseRes(events: unknown[]) {
|
||||
const enc = new TextEncoder();
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(c) {
|
||||
for (const e of events) c.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
|
||||
c.close();
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "text/event-stream" } },
|
||||
);
|
||||
}
|
||||
|
||||
type Handler = (url: string, init?: RequestInit) => Response | Promise<Response>;
|
||||
|
||||
function mockFetch(overrides: Record<string, Handler>, serverChat: () => GielieChat = () => chatFixture()) {
|
||||
const fn = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const method = init?.method ?? "GET";
|
||||
const key = `${method} ${url}`;
|
||||
if (overrides[key]) return overrides[key](url, init);
|
||||
if (key === "GET /api/v1/giellie/status") return jsonRes({ ready: true });
|
||||
if (key === "GET /api/v1/giellie/chats") return jsonRes({ chats: [] });
|
||||
if (key === "POST /api/v1/giellie/chats") return jsonRes({ chat: chatFixture() });
|
||||
if (key === "GET /api/v1/giellie/chats/c1") return jsonRes({ chat: serverChat() });
|
||||
return jsonRes({ error: `unmocked ${key}` }, 404);
|
||||
});
|
||||
vi.stubGlobal("fetch", fn);
|
||||
return fn;
|
||||
}
|
||||
|
||||
beforeEach(() => vi.unstubAllGlobals());
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("useGielie.send", () => {
|
||||
it("calls onAccepted once the server accepts the message (200 stream)", async () => {
|
||||
mockFetch({
|
||||
"POST /api/v1/giellie/chats/c1/messages": () => sseRes([{ type: "text", delta: "hi" }, { type: "done" }]),
|
||||
});
|
||||
const { result } = renderHook(() => useGielie());
|
||||
await waitFor(() => expect(result.current.ready).not.toBeNull());
|
||||
const onAccepted = vi.fn();
|
||||
await act(async () => {
|
||||
await result.current.send("hello", [], false, onAccepted);
|
||||
});
|
||||
expect(onAccepted).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.error).toBe("");
|
||||
});
|
||||
|
||||
it("does not call onAccepted when the server rejects with 413 JSON, and exposes the error", async () => {
|
||||
mockFetch({
|
||||
"POST /api/v1/giellie/chats/c1/messages": () => jsonRes({ error: "File too large" }, 413),
|
||||
});
|
||||
const { result } = renderHook(() => useGielie());
|
||||
await waitFor(() => expect(result.current.ready).not.toBeNull());
|
||||
const onAccepted = vi.fn();
|
||||
await act(async () => {
|
||||
await result.current.send("hello", [], false, onAccepted);
|
||||
});
|
||||
expect(onAccepted).not.toHaveBeenCalled();
|
||||
expect(result.current.error).toBe("File too large");
|
||||
expect(result.current.busy).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores a second send while one is running (synchronous guard)", async () => {
|
||||
let release: () => void = () => {};
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
const fn = mockFetch({
|
||||
"POST /api/v1/giellie/chats/c1/messages": async () => {
|
||||
await gate;
|
||||
return sseRes([{ type: "done" }]);
|
||||
},
|
||||
});
|
||||
const { result } = renderHook(() => useGielie());
|
||||
await waitFor(() => expect(result.current.ready).not.toBeNull());
|
||||
let p1: Promise<void> = Promise.resolve();
|
||||
let p2: Promise<void> = Promise.resolve();
|
||||
await act(async () => {
|
||||
p1 = result.current.send("one", [], false);
|
||||
p2 = result.current.send("two", [], false);
|
||||
release();
|
||||
await Promise.all([p1, p2]);
|
||||
});
|
||||
const posts = fn.mock.calls.filter(([u, i]) => String(u).endsWith("/messages") && i?.method === "POST");
|
||||
expect(posts).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useGielie.decide", () => {
|
||||
it("restores pending from the server when the decision request fails", async () => {
|
||||
mockFetch(
|
||||
{ "POST /api/v1/giellie/chats/c1/decision": () => jsonRes({ error: "boom" }, 500) },
|
||||
() => chatFixture({ pending_action: pendingAction }),
|
||||
);
|
||||
const { result } = renderHook(() => useGielie());
|
||||
await waitFor(() => expect(result.current.ready).not.toBeNull());
|
||||
await act(async () => {
|
||||
await result.current.openChat("c1");
|
||||
});
|
||||
expect(result.current.pending).toEqual(pendingAction);
|
||||
await act(async () => {
|
||||
await result.current.decide(true, false);
|
||||
});
|
||||
expect(result.current.error).toBe("boom");
|
||||
expect(result.current.pending).toEqual(pendingAction);
|
||||
expect(result.current.busy).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useGielie.openChat", () => {
|
||||
it("sets an error instead of rejecting when loading fails", async () => {
|
||||
mockFetch({ "GET /api/v1/giellie/chats/zzz": () => jsonRes({ error: "Not found" }, 404) });
|
||||
const { result } = renderHook(() => useGielie());
|
||||
await waitFor(() => expect(result.current.ready).not.toBeNull());
|
||||
await act(async () => {
|
||||
await result.current.openChat("zzz");
|
||||
});
|
||||
expect(result.current.error).toBe("Not found");
|
||||
});
|
||||
});
|
||||
|
|
@ -35,6 +35,9 @@ export function useGielie() {
|
|||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const chatIdRef = useRef<string | null>(null);
|
||||
// Synchronous in-flight guard: state updates are async, so a fast double
|
||||
// click could otherwise start two requests before `busy` re-renders.
|
||||
const inFlightRef = useRef(false);
|
||||
|
||||
const refreshList = useCallback(async () => {
|
||||
const { chats } = await json<{ chats: GielieChatSummary[] }>(
|
||||
|
|
@ -43,7 +46,8 @@ export function useGielie() {
|
|||
setChats(chats);
|
||||
}, []);
|
||||
|
||||
const openChat = useCallback(async (id: string) => {
|
||||
/** Loads a chat transcript; throws on failure (internal use). */
|
||||
const loadChat = useCallback(async (id: string) => {
|
||||
const { chat } = await json<{ chat: GielieChat }>(
|
||||
await fetch(`/api/v1/giellie/chats/${id}`, { headers: authHeaders() }),
|
||||
);
|
||||
|
|
@ -54,6 +58,18 @@ export function useGielie() {
|
|||
setActivity([]);
|
||||
}, []);
|
||||
|
||||
/** UI-facing: never rejects, surfaces the failure as `error`. */
|
||||
const openChat = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await loadChat(id);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to open chat.");
|
||||
}
|
||||
},
|
||||
[loadChat],
|
||||
);
|
||||
|
||||
const newChat = useCallback(() => {
|
||||
chatIdRef.current = null;
|
||||
setChat(null);
|
||||
|
|
@ -102,11 +118,13 @@ export function useGielie() {
|
|||
}, []);
|
||||
|
||||
const consume = useCallback(
|
||||
async (res: Response, chatId: string) => {
|
||||
async (res: Response, chatId: string, onAccepted?: () => void) => {
|
||||
if (!res.ok || !res.body) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error((body as { error?: string }).error ?? `Request failed (${res.status})`);
|
||||
}
|
||||
// Pre-stream validation passed: the server owns the message now.
|
||||
onAccepted?.();
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const parser = createEventParser();
|
||||
|
|
@ -116,14 +134,16 @@ export function useGielie() {
|
|||
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 loadChat(chatId);
|
||||
await refreshList();
|
||||
},
|
||||
[apply, openChat, refreshList],
|
||||
[apply, loadChat, refreshList],
|
||||
);
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string, files: File[], trust: boolean) => {
|
||||
async (text: string, files: File[], trust: boolean, onAccepted?: () => void) => {
|
||||
if (inFlightRef.current) return;
|
||||
inFlightRef.current = true;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setLiveText("");
|
||||
|
|
@ -168,21 +188,24 @@ export function useGielie() {
|
|||
await consume(
|
||||
await fetch(`/api/v1/giellie/chats/${id}/messages`, { method: "POST", headers: authHeaders(), body: fd }),
|
||||
id,
|
||||
onAccepted,
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Send failed.");
|
||||
if (chatIdRef.current) await openChat(chatIdRef.current).catch(() => {});
|
||||
if (chatIdRef.current) await loadChat(chatIdRef.current).catch(() => {});
|
||||
} finally {
|
||||
inFlightRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[consume, openChat],
|
||||
[consume, loadChat],
|
||||
);
|
||||
|
||||
const decide = useCallback(
|
||||
async (approve: boolean, trust: boolean) => {
|
||||
const id = chatIdRef.current;
|
||||
if (!id) return;
|
||||
if (!id || inFlightRef.current) return;
|
||||
inFlightRef.current = true;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setPending(null);
|
||||
|
|
@ -199,12 +222,13 @@ export function useGielie() {
|
|||
);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Decision failed.");
|
||||
await openChat(id).catch(() => {});
|
||||
await loadChat(id).catch(() => {});
|
||||
} finally {
|
||||
inFlightRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[consume, openChat],
|
||||
[consume, loadChat],
|
||||
);
|
||||
|
||||
return { ready, chats, chat, liveText, activity, pending, busy, error, openChat, newChat, send, decide };
|
||||
|
|
|
|||
19
src/components/markdown.test.tsx
Normal file
19
src/components/markdown.test.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { Markdown } from "./markdown";
|
||||
|
||||
const md = "hello  world";
|
||||
|
||||
describe("Markdown images", () => {
|
||||
it("renders an <img> by default (SOP pages unchanged)", () => {
|
||||
const { container } = render(<Markdown>{md}</Markdown>);
|
||||
expect(container.querySelector("img")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders no <img> when allowImages is false", () => {
|
||||
const { container } = render(<Markdown allowImages={false}>{md}</Markdown>);
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(container.textContent).toContain("hello");
|
||||
expect(container.textContent).not.toContain("SECRET");
|
||||
});
|
||||
});
|
||||
|
|
@ -12,9 +12,22 @@ import remarkGfm from "remark-gfm";
|
|||
* styles. If a new markdown shape shows up in an SOP, add a class
|
||||
* here rather than introducing the typography plugin.
|
||||
*/
|
||||
export function Markdown({ children }: { children: string }) {
|
||||
export function Markdown({
|
||||
children,
|
||||
allowImages = true,
|
||||
}: {
|
||||
children: string;
|
||||
/**
|
||||
* Set false for untrusted text (e.g. model replies): a markdown image is a
|
||||
* zero-click request to an arbitrary host and can exfiltrate data in its URL.
|
||||
*/
|
||||
allowImages?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={allowImages ? components : noImageComponents}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
|
|
@ -193,3 +206,9 @@ const components: Components = {
|
|||
return <input type={type} {...props} />;
|
||||
},
|
||||
};
|
||||
|
||||
/** Same styling, but images render nothing. */
|
||||
const noImageComponents: Components = {
|
||||
...components,
|
||||
img: () => null,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue