feat: journal dashboard widgets, agent polish, and edit-mode live previews
CI / checks (push) Failing after 2m7s
CI / build (push) Successful in 4m36s

Journal dashboard widgets and quick-add; rich-text quick-add dialogs.

Dashboard draft sync for live edit previews; assistant bubble + API tools.

Journal UX: stress slider, mood grid, query cap fix.
This commit is contained in:
ginnoir
2026-07-04 22:03:45 -05:00
parent 4a924a4107
commit a09747c314
76 changed files with 4594 additions and 611 deletions
@@ -0,0 +1,194 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Loader2, Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { consumeAgentChatStream } from "../assistant-chat-stream";
import {
clearAssistantChat,
loadAssistantChat,
saveAssistantChat,
type AssistantChatMessage,
} from "../assistant-chat-storage";
type Props = {
configured: boolean;
userId: string;
};
export function AssistantPanel({ configured, userId }: Props) {
const [messages, setMessages] = useState<AssistantChatMessage[]>(() => loadAssistantChat(userId));
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false);
const [activityLabel, setActivityLabel] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
saveAssistantChat(userId, messages);
}, [messages, userId]);
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
function scrollToBottom() {
requestAnimationFrame(() => {
const node = listRef.current;
if (node) node.scrollTop = node.scrollHeight;
});
}
function clearChat() {
abortRef.current?.abort();
setMessages([]);
setError(null);
setActivityLabel(null);
setIsPending(false);
clearAssistantChat(userId);
}
async function sendMessage() {
const text = input.trim();
if (!text || isPending) return;
const nextMessages: AssistantChatMessage[] = [...messages, { role: "user", content: text }];
setInput("");
setError(null);
setMessages(nextMessages);
setIsPending(true);
setActivityLabel("Understanding your request…");
scrollToBottom();
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
try {
const response = await fetch("/api/agent/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: nextMessages, stream: true }),
signal: controller.signal,
});
const result = await consumeAgentChatStream(response, (event) => {
if (event.type === "thinking" || event.type === "tool" || event.type === "responding") {
setActivityLabel(event.label);
scrollToBottom();
}
});
if (!result.message.content) {
throw new Error("Assistant returned an empty response");
}
setMessages((current) => [...current, result.message]);
scrollToBottom();
} catch (err) {
if (err instanceof Error && err.name === "AbortError") return;
setError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setIsPending(false);
setActivityLabel(null);
abortRef.current = null;
}
}
const showEmptyState = messages.length === 0 && !isPending;
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<p className="muted min-w-0 text-[12px] leading-relaxed">
{configured
? "Ask me to update lists, calendar, notes, or journal."
: "Mock provider active — set LLM_BASE_URL for your homelab model."}
</p>
{messages.length > 0 ? (
<button
type="button"
onClick={clearChat}
disabled={isPending}
className="shrink-0 text-[11px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
>
Clear
</button>
) : null}
</div>
<div
ref={listRef}
className="min-h-0 flex-1 overflow-y-auto rounded-[var(--r-md)] border-[0.5px] bg-[var(--shade)] p-3"
style={{ borderColor: "var(--hair)" }}
>
{showEmptyState ? (
<p className="muted text-[12px]">
Try &quot;add milk to the shopping list&quot; or &quot;what&apos;s on the calendar this
week?&quot;
</p>
) : (
<div className="grid gap-2">
{messages.map((message, index) => (
<div
key={`${message.role}-${index}`}
className={`rounded-lg px-2.5 py-2 text-[12.5px] leading-relaxed whitespace-pre-wrap ${
message.role === "user"
? "ml-6 bg-[var(--card)]"
: "mr-6 border-[0.5px] bg-[var(--card)]"
}`}
style={message.role === "assistant" ? { borderColor: "var(--hair)" } : undefined}
>
<div className="eyebrow mb-0.5 text-[10px]">
{message.role === "user" ? "You" : "Assistant"}
</div>
{message.content}
</div>
))}
{isPending ? (
<div
className="mr-6 rounded-lg border-[0.5px] bg-[var(--card)] px-2.5 py-2"
style={{ borderColor: "var(--hair)" }}
aria-live="polite"
aria-busy="true"
>
<div className="eyebrow mb-1 text-[10px]">Assistant</div>
<div className="flex items-center gap-2 text-[12.5px] text-muted-foreground">
<Loader2 className="size-3.5 shrink-0 animate-spin" />
<span>{activityLabel ?? "Working…"}</span>
</div>
</div>
) : null}
</div>
)}
</div>
{error ? <p className="text-[12px] text-destructive">{error}</p> : null}
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault();
sendMessage();
}}
>
<Input
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Ask the assistant…"
disabled={isPending}
aria-label="Assistant message"
className="h-9"
/>
<Button type="submit" size="sm" disabled={isPending || !input.trim()} aria-label="Send">
{isPending ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
</Button>
</form>
</div>
);
}